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 ; + 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 */}
{ + 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. +

+
+
+ +
+
-
+ )} ); }; diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 0dddd37..1477408 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -260,4 +260,9 @@ export const formatRelativeTime = (date) => { return `${seconds} second${seconds > 1 ? "s" : ""} ago`; }; +// Search API +export const searchAPI = { + global: (query) => api.get("/search", { params: { q: query } }), +}; + export default api;