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. +

+
+
+ +
+
-
+ )} ); };