mirror of
				https://github.com/9technologygroup/patchmon.net.git
				synced 2025-10-31 03:53:51 +00:00 
			
		
		
		
	feat: Add Proxmox LXC auto-enrollment integration
- Add auto_enrollment_tokens table with rate limiting and IP whitelisting - Create backend API routes for token management and enrollment - Build frontend UI for token creation and management - Add one-liner curl command for easy Proxmox deployment - Include Proxmox LXC discovery and enrollment script - Support future integrations with /proxmox-lxc endpoint pattern - Add comprehensive documentation Security features: - Hashed token secrets - Per-day rate limits - IP whitelist support - Token expiration - Separate enrollment vs host API credentials
This commit is contained in:
		| @@ -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 | ||||
|   | ||||
							
								
								
									
										242
									
								
								agents/proxmox_auto_enroll.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										242
									
								
								agents/proxmox_auto_enroll.sh
									
									
									
									
									
										Executable file
									
								
							| @@ -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 | ||||
|  | ||||
| @@ -28,6 +28,7 @@ model host_groups { | ||||
|   created_at              DateTime                  @default(now()) | ||||
|   updated_at              DateTime | ||||
|   hosts                   hosts[] | ||||
|   auto_enrollment_tokens  auto_enrollment_tokens[] | ||||
| } | ||||
|  | ||||
| model host_packages { | ||||
| @@ -188,6 +189,7 @@ model users { | ||||
|   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]) | ||||
| } | ||||
|   | ||||
							
								
								
									
										724
									
								
								backend/src/routes/autoEnrollmentRoutes.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										724
									
								
								backend/src/routes/autoEnrollmentRoutes.js
									
									
									
									
									
										Normal file
									
								
							| @@ -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; | ||||
| @@ -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) => { | ||||
|   | ||||
| @@ -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 ( | ||||
| 		<SettingsLayout> | ||||
| 			<div className="space-y-6"> | ||||
| @@ -12,36 +169,516 @@ const Integrations = () => { | ||||
| 							Integrations | ||||
| 						</h1> | ||||
| 						<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400"> | ||||
| 							Connect PatchMon to third-party services | ||||
| 							Manage auto-enrollment tokens for Proxmox and other integrations | ||||
| 						</p> | ||||
| 					</div> | ||||
| 					<button | ||||
| 						type="button" | ||||
| 						onClick={() => setShowCreateModal(true)} | ||||
| 						className="btn-primary flex items-center gap-2" | ||||
| 					> | ||||
| 						<Plus className="h-4 w-4" /> | ||||
| 						New Token | ||||
| 					</button> | ||||
| 				</div> | ||||
|  | ||||
| 				{/* Proxmox Integration Section */} | ||||
| 				<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6"> | ||||
| 					<div className="flex items-center gap-3 mb-4"> | ||||
| 						<div className="w-10 h-10 bg-primary-100 dark:bg-primary-900 rounded-lg flex items-center justify-center"> | ||||
| 							<Server className="h-5 w-5 text-primary-600 dark:text-primary-400" /> | ||||
| 						</div> | ||||
| 						<div> | ||||
| 							<h3 className="text-lg font-semibold text-secondary-900 dark:text-white"> | ||||
| 								Proxmox LXC Auto-Enrollment | ||||
| 							</h3> | ||||
| 							<p className="text-sm text-secondary-600 dark:text-secondary-400"> | ||||
| 								Automatically discover and enroll LXC containers from Proxmox | ||||
| 								hosts | ||||
| 							</p> | ||||
| 						</div> | ||||
| 					</div> | ||||
|  | ||||
| 				{/* Coming Soon Card */} | ||||
| 				<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6"> | ||||
| 					<div className="flex items-center gap-4"> | ||||
| 						<div className="flex-shrink-0"> | ||||
| 							<div className="w-12 h-12 bg-secondary-100 dark:bg-secondary-700 rounded-lg flex items-center justify-center"> | ||||
| 								<Plug className="h-6 w-6 text-secondary-700 dark:text-secondary-200" /> | ||||
| 					{/* Token List */} | ||||
| 					{loading ? ( | ||||
| 						<div className="text-center py-8"> | ||||
| 							<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600" /> | ||||
| 						</div> | ||||
| 						</div> | ||||
| 						<div className="flex-1"> | ||||
| 							<h3 className="text-lg font-semibold text-secondary-900 dark:text-white"> | ||||
| 								Integrations Coming Soon | ||||
| 							</h3> | ||||
| 							<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400"> | ||||
| 								We are building integrations for Slack, Discord, email, and | ||||
| 								webhooks to streamline alerts and workflows. | ||||
| 					) : tokens.length === 0 ? ( | ||||
| 						<div className="text-center py-8 text-secondary-600 dark:text-secondary-400"> | ||||
| 							<p>No auto-enrollment tokens created yet.</p> | ||||
| 							<p className="text-sm mt-2"> | ||||
| 								Create a token to enable automatic host enrollment from Proxmox. | ||||
| 							</p> | ||||
| 							<div className="mt-3"> | ||||
| 								<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary-100 text-secondary-800 dark:bg-secondary-700 dark:text-secondary-200"> | ||||
| 									In Development | ||||
| 						</div> | ||||
| 					) : ( | ||||
| 						<div className="space-y-3"> | ||||
| 							{tokens.map((token) => ( | ||||
| 								<div | ||||
| 									key={token.id} | ||||
| 									className="border border-secondary-200 dark:border-secondary-600 rounded-lg p-4 hover:border-primary-300 dark:hover:border-primary-700 transition-colors" | ||||
| 								> | ||||
| 									<div className="flex justify-between items-start"> | ||||
| 										<div className="flex-1"> | ||||
| 											<div className="flex items-center gap-2 flex-wrap"> | ||||
| 												<h4 className="font-medium text-secondary-900 dark:text-white"> | ||||
| 													{token.token_name} | ||||
| 												</h4> | ||||
| 												<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"> | ||||
| 													Proxmox LXC | ||||
| 												</span> | ||||
| 												{token.is_active ? ( | ||||
| 													<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"> | ||||
| 														Active | ||||
| 													</span> | ||||
| 												) : ( | ||||
| 													<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-secondary-100 text-secondary-800 dark:bg-secondary-700 dark:text-secondary-200"> | ||||
| 														Inactive | ||||
| 													</span> | ||||
| 												)} | ||||
| 											</div> | ||||
| 											<div className="mt-2 space-y-1 text-sm text-secondary-600 dark:text-secondary-400"> | ||||
| 												<div className="flex items-center gap-2"> | ||||
| 													<span className="font-mono text-xs bg-secondary-100 dark:bg-secondary-700 px-2 py-1 rounded"> | ||||
| 														{token.token_key} | ||||
| 													</span> | ||||
| 													<button | ||||
| 														type="button" | ||||
| 														onClick={() => | ||||
| 															copy_to_clipboard( | ||||
| 																token.token_key, | ||||
| 																`key-${token.id}`, | ||||
| 															) | ||||
| 														} | ||||
| 														className="text-primary-600 hover:text-primary-700 dark:text-primary-400" | ||||
| 													> | ||||
| 														{copy_success[`key-${token.id}`] ? ( | ||||
| 															<CheckCircle className="h-4 w-4" /> | ||||
| 														) : ( | ||||
| 															<Copy className="h-4 w-4" /> | ||||
| 														)} | ||||
| 													</button> | ||||
| 												</div> | ||||
| 												<p> | ||||
| 													Usage: {token.hosts_created_today}/ | ||||
| 													{token.max_hosts_per_day} hosts today | ||||
| 												</p> | ||||
| 												{token.host_groups && ( | ||||
| 													<p> | ||||
| 														Default Group:{" "} | ||||
| 														<span | ||||
| 															className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium" | ||||
| 															style={{ | ||||
| 																backgroundColor: `${token.host_groups.color}20`, | ||||
| 																color: token.host_groups.color, | ||||
| 															}} | ||||
| 														> | ||||
| 															{token.host_groups.name} | ||||
| 														</span> | ||||
| 													</p> | ||||
| 												)} | ||||
| 												{token.allowed_ip_ranges?.length > 0 && ( | ||||
| 													<p> | ||||
| 														Allowed IPs: {token.allowed_ip_ranges.join(", ")} | ||||
| 													</p> | ||||
| 												)} | ||||
| 												<p>Created: {format_date(token.created_at)}</p> | ||||
| 												{token.last_used_at && ( | ||||
| 													<p>Last Used: {format_date(token.last_used_at)}</p> | ||||
| 												)} | ||||
| 												{token.expires_at && ( | ||||
| 													<p> | ||||
| 														Expires: {format_date(token.expires_at)} | ||||
| 														{new Date(token.expires_at) < new Date() && ( | ||||
| 															<span className="ml-2 text-red-600 dark:text-red-400"> | ||||
| 																(Expired) | ||||
| 															</span> | ||||
| 														)} | ||||
| 													</p> | ||||
| 												)} | ||||
| 											</div> | ||||
| 										</div> | ||||
| 										<div className="flex items-center gap-2"> | ||||
| 											<button | ||||
| 												type="button" | ||||
| 												onClick={() => | ||||
| 													toggle_token_active(token.id, token.is_active) | ||||
| 												} | ||||
| 												className={`px-3 py-1 text-sm rounded ${ | ||||
| 													token.is_active | ||||
| 														? "bg-secondary-100 text-secondary-700 hover:bg-secondary-200 dark:bg-secondary-700 dark:text-secondary-300" | ||||
| 														: "bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900 dark:text-green-300" | ||||
| 												}`} | ||||
| 											> | ||||
| 												{token.is_active ? "Disable" : "Enable"} | ||||
| 											</button> | ||||
| 											<button | ||||
| 												type="button" | ||||
| 												onClick={() => delete_token(token.id, token.token_name)} | ||||
| 												className="text-red-600 hover:text-red-800 dark:text-red-400 p-2" | ||||
| 											> | ||||
| 												<Trash2 className="h-4 w-4" /> | ||||
| 											</button> | ||||
| 										</div> | ||||
| 									</div> | ||||
| 								</div> | ||||
| 							))} | ||||
| 						</div> | ||||
| 					)} | ||||
| 				</div> | ||||
|  | ||||
| 				{/* Documentation Section */} | ||||
| 				<div className="bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800 rounded-lg p-6"> | ||||
| 					<h3 className="text-lg font-semibold text-primary-900 dark:text-primary-200 mb-3"> | ||||
| 						How to Use Auto-Enrollment | ||||
| 					</h3> | ||||
| 					<ol className="list-decimal list-inside space-y-2 text-sm text-primary-800 dark:text-primary-300"> | ||||
| 						<li>Create a new auto-enrollment token using the button above</li> | ||||
| 						<li> | ||||
| 							Copy the one-line installation command shown in the success dialog | ||||
| 						</li> | ||||
| 						<li>SSH into your Proxmox host as root</li> | ||||
| 						<li> | ||||
| 							Paste and run the command - it will automatically discover and | ||||
| 							enroll all running LXC containers | ||||
| 						</li> | ||||
| 						<li>View enrolled containers in the Hosts page</li> | ||||
| 					</ol> | ||||
| 					<div className="mt-4 p-3 bg-primary-100 dark:bg-primary-900/40 rounded border border-primary-200 dark:border-primary-700"> | ||||
| 						<p className="text-xs text-primary-800 dark:text-primary-300"> | ||||
| 							<strong>💡 Tip:</strong> You can run the same command multiple | ||||
| 							times safely - already enrolled containers will be automatically | ||||
| 							skipped. | ||||
| 						</p> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 			</div> | ||||
|  | ||||
| 			{/* Create Token Modal */} | ||||
| 			{show_create_modal && ( | ||||
| 				<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"> | ||||
| 					<div className="bg-white dark:bg-secondary-800 rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto"> | ||||
| 						<div className="p-6"> | ||||
| 							<div className="flex items-center justify-between mb-6"> | ||||
| 								<h2 className="text-xl font-bold text-secondary-900 dark:text-white"> | ||||
| 									Create Auto-Enrollment Token | ||||
| 								</h2> | ||||
| 								<button | ||||
| 									type="button" | ||||
| 									onClick={() => setShowCreateModal(false)} | ||||
| 									className="text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-200" | ||||
| 								> | ||||
| 									<X className="h-6 w-6" /> | ||||
| 								</button> | ||||
| 							</div> | ||||
|  | ||||
| 							<form onSubmit={create_token} className="space-y-4"> | ||||
| 								<label className="block"> | ||||
| 									<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1"> | ||||
| 										Token Name * | ||||
| 									</span> | ||||
| 									<input | ||||
| 										type="text" | ||||
| 										required | ||||
| 										value={form_data.token_name} | ||||
| 										onChange={(e) => | ||||
| 											setFormData({ ...form_data, token_name: e.target.value }) | ||||
| 										} | ||||
| 										placeholder="e.g., Proxmox Production" | ||||
| 										className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white" | ||||
| 									/> | ||||
| 								</label> | ||||
|  | ||||
| 								<label className="block"> | ||||
| 									<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1"> | ||||
| 										Max Hosts Per Day | ||||
| 									</span> | ||||
| 									<input | ||||
| 										type="number" | ||||
| 										min="1" | ||||
| 										max="1000" | ||||
| 										value={form_data.max_hosts_per_day} | ||||
| 										onChange={(e) => | ||||
| 											setFormData({ | ||||
| 												...form_data, | ||||
| 												max_hosts_per_day: e.target.value, | ||||
| 											}) | ||||
| 										} | ||||
| 										className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white" | ||||
| 									/> | ||||
| 									<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400"> | ||||
| 										Maximum number of hosts that can be enrolled per day using | ||||
| 										this token | ||||
| 									</p> | ||||
| 								</label> | ||||
|  | ||||
| 								<label className="block"> | ||||
| 									<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1"> | ||||
| 										Default Host Group (Optional) | ||||
| 									</span> | ||||
| 									<select | ||||
| 										value={form_data.default_host_group_id} | ||||
| 										onChange={(e) => | ||||
| 											setFormData({ | ||||
| 												...form_data, | ||||
| 												default_host_group_id: e.target.value, | ||||
| 											}) | ||||
| 										} | ||||
| 										className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white" | ||||
| 									> | ||||
| 										<option value="">No default group</option> | ||||
| 										{host_groups.map((group) => ( | ||||
| 											<option key={group.id} value={group.id}> | ||||
| 												{group.name} | ||||
| 											</option> | ||||
| 										))} | ||||
| 									</select> | ||||
| 									<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400"> | ||||
| 										Auto-enrolled hosts will be assigned to this group | ||||
| 									</p> | ||||
| 								</label> | ||||
|  | ||||
| 								<label className="block"> | ||||
| 									<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1"> | ||||
| 										Allowed IP Addresses (Optional) | ||||
| 									</span> | ||||
| 									<input | ||||
| 										type="text" | ||||
| 										value={form_data.allowed_ip_ranges} | ||||
| 										onChange={(e) => | ||||
| 											setFormData({ | ||||
| 												...form_data, | ||||
| 												allowed_ip_ranges: e.target.value, | ||||
| 											}) | ||||
| 										} | ||||
| 										placeholder="e.g., 192.168.1.100, 10.0.0.50" | ||||
| 										className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white" | ||||
| 									/> | ||||
| 									<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400"> | ||||
| 										Comma-separated list of IP addresses allowed to use this | ||||
| 										token | ||||
| 									</p> | ||||
| 								</label> | ||||
|  | ||||
| 								<label className="block"> | ||||
| 									<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1"> | ||||
| 										Expiration Date (Optional) | ||||
| 									</span> | ||||
| 									<input | ||||
| 										type="datetime-local" | ||||
| 										value={form_data.expires_at} | ||||
| 										onChange={(e) => | ||||
| 											setFormData({ ...form_data, expires_at: e.target.value }) | ||||
| 										} | ||||
| 										className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white" | ||||
| 									/> | ||||
| 								</label> | ||||
|  | ||||
| 								<div className="flex gap-3 pt-4"> | ||||
| 									<button | ||||
| 										type="submit" | ||||
| 										className="flex-1 btn-primary py-2 px-4 rounded-md" | ||||
| 									> | ||||
| 										Create Token | ||||
| 									</button> | ||||
| 									<button | ||||
| 										type="button" | ||||
| 										onClick={() => setShowCreateModal(false)} | ||||
| 										className="flex-1 bg-secondary-100 dark:bg-secondary-700 text-secondary-700 dark:text-secondary-300 py-2 px-4 rounded-md hover:bg-secondary-200 dark:hover:bg-secondary-600" | ||||
| 									> | ||||
| 										Cancel | ||||
| 									</button> | ||||
| 								</div> | ||||
| 							</form> | ||||
| 						</div> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 			)} | ||||
|  | ||||
| 			{/* New Token Display Modal */} | ||||
| 			{new_token && ( | ||||
| 				<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"> | ||||
| 					<div className="bg-white dark:bg-secondary-800 rounded-lg max-w-2xl w-full"> | ||||
| 						<div className="p-6"> | ||||
| 							<div className="flex items-start gap-3 mb-6"> | ||||
| 								<div className="flex-shrink-0"> | ||||
| 									<CheckCircle className="h-8 w-8 text-green-600 dark:text-green-400" /> | ||||
| 								</div> | ||||
| 								<div> | ||||
| 									<h2 className="text-xl font-bold text-secondary-900 dark:text-white"> | ||||
| 										Token Created Successfully | ||||
| 									</h2> | ||||
| 									<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400"> | ||||
| 										Save these credentials now - the secret will not be shown | ||||
| 										again! | ||||
| 									</p> | ||||
| 								</div> | ||||
| 							</div> | ||||
|  | ||||
| 							<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 mb-6"> | ||||
| 								<div className="flex items-start gap-2"> | ||||
| 									<AlertCircle className="h-5 w-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" /> | ||||
| 									<p className="text-sm text-yellow-800 dark:text-yellow-200"> | ||||
| 										<strong>Important:</strong> Store the token secret securely. | ||||
| 										You will not be able to view it again after closing this | ||||
| 										dialog. | ||||
| 									</p> | ||||
| 								</div> | ||||
| 							</div> | ||||
|  | ||||
| 							<div className="space-y-4"> | ||||
| 								<div> | ||||
| 									<div className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2"> | ||||
| 										Token Name | ||||
| 									</div> | ||||
| 									<div className="flex items-center gap-2"> | ||||
| 										<input | ||||
| 											type="text" | ||||
| 											value={new_token.token_name} | ||||
| 											readOnly | ||||
| 											className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-sm" | ||||
| 										/> | ||||
| 									</div> | ||||
| 								</div> | ||||
|  | ||||
| 								<div> | ||||
| 									<div className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2"> | ||||
| 										Token Key | ||||
| 									</div> | ||||
| 									<div className="flex items-center gap-2"> | ||||
| 										<input | ||||
| 											type="text" | ||||
| 											value={new_token.token_key} | ||||
| 											readOnly | ||||
| 											className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-sm" | ||||
| 										/> | ||||
| 										<button | ||||
| 											type="button" | ||||
| 											onClick={() => | ||||
| 												copy_to_clipboard(new_token.token_key, "new-key") | ||||
| 											} | ||||
| 											className="btn-primary flex items-center gap-1 px-3 py-2" | ||||
| 										> | ||||
| 											{copy_success["new-key"] ? ( | ||||
| 												<> | ||||
| 													<CheckCircle className="h-4 w-4" /> | ||||
| 													Copied | ||||
| 												</> | ||||
| 											) : ( | ||||
| 												<> | ||||
| 													<Copy className="h-4 w-4" /> | ||||
| 													Copy | ||||
| 												</> | ||||
| 											)} | ||||
| 										</button> | ||||
| 									</div> | ||||
| 								</div> | ||||
|  | ||||
| 								<div> | ||||
| 									<div className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2"> | ||||
| 										Token Secret | ||||
| 									</div> | ||||
| 									<div className="flex items-center gap-2"> | ||||
| 										<input | ||||
| 											type={show_secret ? "text" : "password"} | ||||
| 											value={new_token.token_secret} | ||||
| 											readOnly | ||||
| 											className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-sm" | ||||
| 										/> | ||||
| 										<button | ||||
| 											type="button" | ||||
| 											onClick={() => setShowSecret(!show_secret)} | ||||
| 											className="p-2 text-secondary-600 hover:text-secondary-800 dark:text-secondary-400 dark:hover:text-secondary-200" | ||||
| 										> | ||||
| 											{show_secret ? ( | ||||
| 												<EyeOff className="h-5 w-5" /> | ||||
| 											) : ( | ||||
| 												<Eye className="h-5 w-5" /> | ||||
| 											)} | ||||
| 										</button> | ||||
| 										<button | ||||
| 											type="button" | ||||
| 											onClick={() => | ||||
| 												copy_to_clipboard(new_token.token_secret, "new-secret") | ||||
| 											} | ||||
| 											className="btn-primary flex items-center gap-1 px-3 py-2" | ||||
| 										> | ||||
| 											{copy_success["new-secret"] ? ( | ||||
| 												<> | ||||
| 													<CheckCircle className="h-4 w-4" /> | ||||
| 													Copied | ||||
| 												</> | ||||
| 											) : ( | ||||
| 												<> | ||||
| 													<Copy className="h-4 w-4" /> | ||||
| 													Copy | ||||
| 												</> | ||||
| 											)} | ||||
| 										</button> | ||||
| 									</div> | ||||
| 								</div> | ||||
|  | ||||
| 								<div className="mt-6"> | ||||
| 									<div className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2"> | ||||
| 										One-Line Installation Command | ||||
| 									</div> | ||||
| 									<p className="text-xs text-secondary-600 dark:text-secondary-400 mb-2"> | ||||
| 										Run this command on your Proxmox host to download and | ||||
| 										execute the enrollment script: | ||||
| 									</p> | ||||
| 									<div className="flex items-center gap-2"> | ||||
| 										<input | ||||
| 											type="text" | ||||
| 											value={`curl -s "${server_url}/api/v1/auto-enrollment/proxmox-lxc?token_key=${new_token.token_key}&token_secret=${new_token.token_secret}" | bash`} | ||||
| 											readOnly | ||||
| 											className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-xs" | ||||
| 										/> | ||||
| 										<button | ||||
| 											type="button" | ||||
| 											onClick={() => | ||||
| 												copy_to_clipboard( | ||||
| 													`curl -s "${server_url}/api/v1/auto-enrollment/proxmox-lxc?token_key=${new_token.token_key}&token_secret=${new_token.token_secret}" | bash`, | ||||
| 													"curl-command", | ||||
| 												) | ||||
| 											} | ||||
| 											className="btn-primary flex items-center gap-1 px-3 py-2 whitespace-nowrap" | ||||
| 										> | ||||
| 											{copy_success["curl-command"] ? ( | ||||
| 												<> | ||||
| 													<CheckCircle className="h-4 w-4" /> | ||||
| 													Copied | ||||
| 												</> | ||||
| 											) : ( | ||||
| 												<> | ||||
| 													<Copy className="h-4 w-4" /> | ||||
| 													Copy | ||||
| 												</> | ||||
| 											)} | ||||
| 										</button> | ||||
| 									</div> | ||||
| 									<p className="text-xs text-secondary-500 dark:text-secondary-400 mt-2"> | ||||
| 										💡 This command will automatically discover and enroll all | ||||
| 										running LXC containers. | ||||
| 									</p> | ||||
| 								</div> | ||||
| 							</div> | ||||
|  | ||||
| 							<div className="flex gap-3 pt-6"> | ||||
| 								<button | ||||
| 									type="button" | ||||
| 									onClick={() => { | ||||
| 										setNewToken(null); | ||||
| 										setShowSecret(false); | ||||
| 									}} | ||||
| 									className="flex-1 btn-primary py-2 px-4 rounded-md" | ||||
| 								> | ||||
| 									I've Saved the Credentials | ||||
| 								</button> | ||||
| 							</div> | ||||
| 						</div> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 			)} | ||||
| 		</SettingsLayout> | ||||
| 	); | ||||
| }; | ||||
|   | ||||
		Reference in New Issue
	
	Block a user