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..ad573a9
--- /dev/null
+++ b/agents/proxmox_auto_enroll.sh
@@ -0,0 +1,349 @@
+#!/bin/bash
+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.1.0"
+echo "[DEBUG] Script Version: $SCRIPT_VERSION ($(date +%Y-%m-%d\ %H:%M:%S))"
+
+# =============================================================================
+# PatchMon Proxmox LXC Auto-Enrollment Script
+# =============================================================================
+# This script discovers LXC containers on a Proxmox host and automatically
+# enrolls them into PatchMon for patch management.
+#
+# Usage:
+# 1. Set environment variables or edit configuration below
+# 2. Run: bash proxmox_auto_enroll.sh
+#
+# Requirements:
+# - Must run on Proxmox host (requires 'pct' command)
+# - Auto-enrollment token from PatchMon
+# - Network access to PatchMon server
+# =============================================================================
+
+# ===== CONFIGURATION =====
+PATCHMON_URL="${PATCHMON_URL:-https://patchmon.example.com}"
+AUTO_ENROLLMENT_KEY="${AUTO_ENROLLMENT_KEY:-}"
+AUTO_ENROLLMENT_SECRET="${AUTO_ENROLLMENT_SECRET:-}"
+CURL_FLAGS="${CURL_FLAGS:--s}"
+DRY_RUN="${DRY_RUN:-false}"
+HOST_PREFIX="${HOST_PREFIX:-}"
+SKIP_STOPPED="${SKIP_STOPPED:-true}"
+PARALLEL_INSTALL="${PARALLEL_INSTALL:-false}"
+MAX_PARALLEL="${MAX_PARALLEL:-5}"
+
+# ===== COLOR OUTPUT =====
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+NC='\033[0m' # No Color
+
+# ===== LOGGING FUNCTIONS =====
+info() { echo -e "${GREEN}[INFO]${NC} $1"; return 0; }
+warn() { echo -e "${YELLOW}[WARN]${NC} $1"; return 0; }
+error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
+success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; return 0; }
+debug() { [[ "${DEBUG:-false}" == "true" ]] && echo -e "${BLUE}[DEBUG]${NC} $1" || true; return 0; }
+
+# ===== BANNER =====
+cat << "EOF"
+╔═══════════════════════════════════════════════════════════════╗
+║ ║
+║ ____ _ _ __ __ ║
+║ | _ \ __ _| |_ ___| |__ | \/ | ___ _ __ ║
+║ | |_) / _` | __/ __| '_ \| |\/| |/ _ \| '_ \ ║
+║ | __/ (_| | || (__| | | | | | | (_) | | | | ║
+║ |_| \__,_|\__\___|_| |_|_| |_|\___/|_| |_| ║
+║ ║
+║ Proxmox LXC Auto-Enrollment Script ║
+║ ║
+╚═══════════════════════════════════════════════════════════════╝
+EOF
+echo ""
+
+# ===== VALIDATION =====
+info "Validating configuration..."
+
+if [[ -z "$AUTO_ENROLLMENT_KEY" ]] || [[ -z "$AUTO_ENROLLMENT_SECRET" ]]; then
+ error "AUTO_ENROLLMENT_KEY and AUTO_ENROLLMENT_SECRET must be set"
+fi
+
+if [[ -z "$PATCHMON_URL" ]]; then
+ error "PATCHMON_URL must be set"
+fi
+
+# Check if running on Proxmox
+if ! command -v pct &> /dev/null; then
+ error "This script must run on a Proxmox host (pct command not found)"
+fi
+
+# Check for required commands
+for cmd in curl jq; do
+ if ! command -v $cmd &> /dev/null; then
+ error "Required command '$cmd' not found. Please install it first."
+ fi
+done
+
+info "Configuration validated successfully"
+info "PatchMon Server: $PATCHMON_URL"
+info "Dry Run Mode: $DRY_RUN"
+info "Skip Stopped Containers: $SKIP_STOPPED"
+echo ""
+
+# ===== DISCOVER LXC CONTAINERS =====
+info "Discovering LXC containers..."
+lxc_list=$(pct list | tail -n +2) # Skip header
+
+if [[ -z "$lxc_list" ]]; then
+ warn "No LXC containers found on this Proxmox host"
+ exit 0
+fi
+
+# Count containers
+total_containers=$(echo "$lxc_list" | wc -l)
+info "Found $total_containers LXC container(s)"
+echo ""
+
+info "Initializing statistics..."
+# ===== STATISTICS =====
+enrolled_count=0
+skipped_count=0
+failed_count=0
+
+# Track containers with dpkg errors for later recovery
+declare -A dpkg_error_containers
+info "Statistics initialized"
+
+# ===== PROCESS CONTAINERS =====
+info "Starting container processing loop..."
+while IFS= read -r line; do
+ info "[DEBUG] Read line from lxc_list"
+ vmid=$(echo "$line" | awk '{print $1}')
+ status=$(echo "$line" | awk '{print $2}')
+ name=$(echo "$line" | awk '{print $3}')
+
+ info "Processing LXC $vmid: $name (status: $status)"
+
+ # Skip stopped containers if configured
+ if [[ "$status" != "running" ]] && [[ "$SKIP_STOPPED" == "true" ]]; then
+ warn " Skipping $name - container not running"
+ ((skipped_count++)) || true
+ echo ""
+ continue
+ fi
+
+ # Check if container is stopped
+ if [[ "$status" != "running" ]]; then
+ warn " Container $name is stopped - cannot gather info or install agent"
+ ((skipped_count++)) || true
+ echo ""
+ continue
+ fi
+
+ # Get container details
+ debug " Gathering container information..."
+ hostname=$(timeout 5 pct exec "$vmid" -- hostname 2>/dev/null /dev/null /dev/null &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..."
+
+ # 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
+ curl $CURL_FLAGS \
+ -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 && \
+ rm -f patchmon-install.sh
+ " 2>&1 180s) in $friendly_name"
+ info " Install output: $install_output"
+ ((failed_count++)) || true
+ else
+ # Check if it's a dpkg error
+ if [[ "$install_output" == *"dpkg was interrupted"* ]] || [[ "$install_output" == *"dpkg --configure -a"* ]]; then
+ warn " ⚠ Failed due to dpkg error in $friendly_name (can be fixed)"
+ dpkg_error_containers["$vmid"]="$friendly_name:$api_id:$api_key"
+ else
+ warn " ✗ Failed to install agent in $friendly_name (exit: $install_exit_code)"
+ fi
+ info " Install output: $install_output"
+ ((failed_count++)) || true
+ fi
+
+ elif [[ "$http_code" == "409" ]]; then
+ warn " ⊘ Host $friendly_name already enrolled - skipping"
+ ((skipped_count++)) || true
+ elif [[ "$http_code" == "429" ]]; then
+ error " ✗ Rate limit exceeded - maximum hosts per day reached"
+ ((failed_count++)) || true
+ else
+ error " ✗ Failed to enroll $friendly_name - HTTP $http_code"
+ debug " Response: $body"
+ ((failed_count++)) || true
+ fi
+
+ echo ""
+ sleep 1 # Rate limiting between containers
+
+done <<< "$lxc_list"
+
+# ===== SUMMARY =====
+echo ""
+echo "╔═══════════════════════════════════════════════════════════════╗"
+echo "║ ENROLLMENT SUMMARY ║"
+echo "╚═══════════════════════════════════════════════════════════════╝"
+echo ""
+info "Total Containers Found: $total_containers"
+info "Successfully Enrolled: $enrolled_count"
+info "Skipped: $skipped_count"
+info "Failed: $failed_count"
+echo ""
+
+if [[ "$DRY_RUN" == "true" ]]; then
+ warn "This was a DRY RUN - no actual changes were made"
+ warn "Set DRY_RUN=false to perform actual enrollment"
+fi
+
+# ===== DPKG ERROR RECOVERY =====
+if [[ ${#dpkg_error_containers[@]} -gt 0 ]]; then
+ echo ""
+ echo "╔═══════════════════════════════════════════════════════════════╗"
+ echo "║ DPKG ERROR RECOVERY AVAILABLE ║"
+ echo "╚═══════════════════════════════════════════════════════════════╝"
+ echo ""
+ warn "Detected ${#dpkg_error_containers[@]} container(s) with dpkg errors:"
+ for vmid in "${!dpkg_error_containers[@]}"; do
+ IFS=':' read -r name api_id api_key <<< "${dpkg_error_containers[$vmid]}"
+ info " • Container $vmid: $name"
+ done
+ echo ""
+
+ # Ask user if they want to fix dpkg errors
+ read -p "Would you like to fix dpkg errors and retry installation? (y/N): " -n 1 -r
+ echo ""
+
+ if [[ $REPLY =~ ^[Yy]$ ]]; then
+ echo ""
+ info "Starting dpkg recovery process..."
+ echo ""
+
+ recovered_count=0
+
+ for vmid in "${!dpkg_error_containers[@]}"; do
+ IFS=':' read -r name api_id api_key <<< "${dpkg_error_containers[$vmid]}"
+
+ info "Fixing dpkg in container $vmid ($name)..."
+
+ # Run dpkg --configure -a
+ dpkg_output=$(timeout 60 pct exec "$vmid" -- dpkg --configure -a 2>&1 &1 {
+ 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/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 394fe2a..e407f0a 100644
--- a/backend/src/server.js
+++ b/backend/src/server.js
@@ -60,6 +60,8 @@ const {
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");
@@ -414,6 +416,12 @@ 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);
+app.use(
+ `/api/${apiVersion}/auto-enrollment`,
+ authLimiter,
+ autoEnrollmentRoutes,
+);
// 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
+ Automatically discover and enroll LXC containers from Proxmox + hosts +
+No auto-enrollment tokens created yet.
++ Create a token to enable automatic host enrollment from Proxmox. +
++ 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) + + )} +
+ )} ++ 💡 Tip: You can run the same command multiple + times safely - already enrolled containers will be automatically + skipped.
- We are building integrations for Slack, Discord, email, and - webhooks to streamline alerts and workflows. -
-+ 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. +
++ 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. +
+