diff --git a/agents/direct_host_auto_enroll.sh b/agents/direct_host_auto_enroll.sh new file mode 100755 index 0000000..17ece1e --- /dev/null +++ b/agents/direct_host_auto_enroll.sh @@ -0,0 +1,258 @@ +#!/bin/sh +# PatchMon Direct Host Auto-Enrollment Script +# POSIX-compliant shell script (works with dash, ash, bash, etc.) +# Usage: curl -s "https://patchmon.example.com/api/v1/auto-enrollment/script?type=direct-host&token_key=KEY&token_secret=SECRET" | sh + +set -e + +SCRIPT_VERSION="1.0.0" + +# ============================================================================= +# PatchMon Direct Host Auto-Enrollment Script +# ============================================================================= +# This script automatically enrolls the current host into PatchMon for patch +# management. +# +# Usage: +# curl -s "https://patchmon.example.com/api/v1/auto-enrollment/script?type=direct-host&token_key=KEY&token_secret=SECRET" | sh +# +# Requirements: +# - Run as root or with sudo +# - Auto-enrollment token from PatchMon +# - Network access to PatchMon server +# ============================================================================= + +# ===== CONFIGURATION ===== +PATCHMON_URL="${PATCHMON_URL:-https://patchmon.example.com}" +AUTO_ENROLLMENT_KEY="${AUTO_ENROLLMENT_KEY:-}" +AUTO_ENROLLMENT_SECRET="${AUTO_ENROLLMENT_SECRET:-}" +CURL_FLAGS="${CURL_FLAGS:--s}" +FORCE_INSTALL="${FORCE_INSTALL:-false}" + +# ===== 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() { printf "%b\n" "${GREEN}[INFO]${NC} $1"; } +warn() { printf "%b\n" "${YELLOW}[WARN]${NC} $1"; } +error() { printf "%b\n" "${RED}[ERROR]${NC} $1" >&2; exit 1; } +success() { printf "%b\n" "${GREEN}[SUCCESS]${NC} $1"; } +debug() { [ "${DEBUG:-false}" = "true" ] && printf "%b\n" "${BLUE}[DEBUG]${NC} $1" || true; } + +# ===== BANNER ===== +cat << "EOF" +╔═══════════════════════════════════════════════════════════════╗ +║ ║ +║ ____ _ _ __ __ ║ +║ | _ \ __ _| |_ ___| |__ | \/ | ___ _ __ ║ +║ | |_) / _` | __/ __| '_ \| |\/| |/ _ \| '_ \ ║ +║ | __/ (_| | || (__| | | | | | | (_) | | | | ║ +║ |_| \__,_|\__\___|_| |_|_| |_|\___/|_| |_| ║ +║ ║ +║ Direct Host 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 as root +if [ "$(id -u)" -ne 0 ]; then + error "This script must be run as root (use sudo)" +fi + +# Check for required commands +for cmd in curl; 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" +echo "" + +# ===== GATHER HOST INFORMATION ===== +info "Gathering host information..." + +# Get hostname +hostname=$(hostname) +friendly_name="$hostname" + +# Try to get machine_id (optional, for tracking) +machine_id="" +if [ -f /etc/machine-id ]; then + machine_id=$(cat /etc/machine-id 2>/dev/null || echo "") +elif [ -f /var/lib/dbus/machine-id ]; then + machine_id=$(cat /var/lib/dbus/machine-id 2>/dev/null || echo "") +fi + +# Get OS information +os_info="unknown" +if [ -f /etc/os-release ]; then + os_info=$(grep "^PRETTY_NAME=" /etc/os-release 2>/dev/null | cut -d'"' -f2 || echo "unknown") +fi + +# Get IP address (first non-loopback) +ip_address=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "unknown") + +# Detect architecture +arch_raw=$(uname -m 2>/dev/null || echo "unknown") +case "$arch_raw" in + "x86_64") + architecture="amd64" + ;; + "i386"|"i686") + architecture="386" + ;; + "aarch64"|"arm64") + architecture="arm64" + ;; + "armv7l"|"armv6l"|"arm") + architecture="arm" + ;; + *) + warn " ⚠ Unknown architecture '$arch_raw', defaulting to amd64" + architecture="amd64" + ;; +esac + +info "Hostname: $hostname" +info "IP Address: $ip_address" +info "OS: $os_info" +info "Architecture: $architecture" +if [ -n "$machine_id" ]; then + # POSIX-compliant substring (first 16 chars) + machine_id_short=$(printf "%.16s" "$machine_id") + info "Machine ID: ${machine_id_short}..." +else + info "Machine ID: (not available)" +fi +echo "" + +# ===== CHECK IF AGENT ALREADY INSTALLED ===== +info "Checking if agent is already configured..." + +config_check=$(sh -c " + if [ -f /etc/patchmon/config.yml ] && [ -f /etc/patchmon/credentials.yml ]; then + if [ -f /usr/local/bin/patchmon-agent ]; then + # Try to ping using existing configuration + if /usr/local/bin/patchmon-agent ping >/dev/null 2>&1; then + echo 'ping_success' + else + echo 'ping_failed' + fi + else + echo 'binary_missing' + fi + else + echo 'not_configured' + fi +" 2>/dev/null || echo "error") + +if [ "$config_check" = "ping_success" ]; then + success "Host already enrolled and agent ping successful - nothing to do" + exit 0 +elif [ "$config_check" = "ping_failed" ]; then + warn "Agent configuration exists but ping failed - will reinstall" +elif [ "$config_check" = "binary_missing" ]; then + warn "Config exists but agent binary missing - will reinstall" +elif [ "$config_check" = "not_configured" ]; then + info "Agent not yet configured - proceeding with enrollment" +else + warn "Could not check agent status - proceeding with enrollment" +fi +echo "" + +# ===== ENROLL HOST ===== +info "Enrolling $friendly_name in PatchMon..." + +# Build JSON payload +json_payload=$(cat <&1) + +http_code=$(echo "$response" | tail -n 1) +body=$(echo "$response" | sed '$d') + +if [ "$http_code" = "201" ]; then + # Use grep and cut instead of jq since jq may not be installed + api_id=$(echo "$body" | grep -o '"api_id":"[^"]*' | cut -d'"' -f4 || echo "") + api_key=$(echo "$body" | grep -o '"api_key":"[^"]*' | cut -d'"' -f4 || echo "") + + if [ -z "$api_id" ] || [ -z "$api_key" ]; then + error "Failed to parse API credentials from response" + fi + + success "Host enrolled successfully: $api_id" + echo "" + + # ===== INSTALL AGENT ===== + info "Installing PatchMon agent..." + + # Build install URL with force flag and architecture + install_url="$PATCHMON_URL/api/v1/hosts/install?arch=$architecture" + if [ "$FORCE_INSTALL" = "true" ]; then + install_url="$install_url&force=true" + info "Using force mode - will bypass broken packages" + fi + info "Using architecture: $architecture" + + # Download and execute installation script + install_exit_code=0 + install_output=$(curl $CURL_FLAGS \ + -H "X-API-ID: $api_id" \ + -H "X-API-KEY: $api_key" \ + "$install_url" | sh 2>&1) || install_exit_code=$? + + # Check both exit code AND success message in output + if [ "$install_exit_code" -eq 0 ] || echo "$install_output" | grep -q "PatchMon Agent installation completed successfully"; then + success "Agent installed successfully" + else + error "Failed to install agent (exit: $install_exit_code)" + fi +else + printf "%b\n" "${RED}[ERROR]${NC} Failed to enroll $friendly_name - HTTP $http_code" >&2 + printf "%b\n" "Response: $body" >&2 + exit 1 +fi + +echo "" +success "Auto-enrollment complete!" +exit 0 diff --git a/agents/patchmon-agent-linux-386 b/agents/patchmon-agent-linux-386 index 5b105d6..f6aba1a 100755 Binary files a/agents/patchmon-agent-linux-386 and b/agents/patchmon-agent-linux-386 differ diff --git a/agents/patchmon-agent-linux-amd64 b/agents/patchmon-agent-linux-amd64 index 6b6cd2b..5dc274f 100755 Binary files a/agents/patchmon-agent-linux-amd64 and b/agents/patchmon-agent-linux-amd64 differ diff --git a/agents/patchmon-agent-linux-arm b/agents/patchmon-agent-linux-arm index a9ad9f1..22a7b99 100755 Binary files a/agents/patchmon-agent-linux-arm and b/agents/patchmon-agent-linux-arm differ diff --git a/agents/patchmon-agent-linux-arm64 b/agents/patchmon-agent-linux-arm64 index b4d83cf..0b77d66 100755 Binary files a/agents/patchmon-agent-linux-arm64 and b/agents/patchmon-agent-linux-arm64 differ diff --git a/backend/prisma/migrations/20251114215738_remove_machine_id_unique_constraint/migration.sql b/backend/prisma/migrations/20251114215738_remove_machine_id_unique_constraint/migration.sql new file mode 100644 index 0000000..4dcc568 --- /dev/null +++ b/backend/prisma/migrations/20251114215738_remove_machine_id_unique_constraint/migration.sql @@ -0,0 +1,13 @@ +-- Remove machine_id unique constraint and make it nullable +-- This allows multiple hosts with the same machine_id +-- Duplicate detection now relies on config.yml/credentials.yml checking instead + +-- Drop the unique constraint +ALTER TABLE "hosts" DROP CONSTRAINT IF EXISTS "hosts_machine_id_key"; + +-- Make machine_id nullable +ALTER TABLE "hosts" ALTER COLUMN "machine_id" DROP NOT NULL; + +-- Keep the index for query performance (but not unique) +CREATE INDEX IF NOT EXISTS "hosts_machine_id_idx" ON "hosts"("machine_id"); + diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 51d442f..0cd2af5 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -81,7 +81,7 @@ model host_repositories { model hosts { id String @id - machine_id String @unique + machine_id String? friendly_name String ip String? os_type String diff --git a/backend/src/routes/autoEnrollmentRoutes.js b/backend/src/routes/autoEnrollmentRoutes.js index 96d233a..0ebaaff 100644 --- a/backend/src/routes/autoEnrollmentRoutes.js +++ b/backend/src/routes/autoEnrollmentRoutes.js @@ -481,19 +481,22 @@ router.delete( ); // ========== 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 +// Universal script-serving endpoint with type parameter +// Supported types: +// - proxmox-lxc - Proxmox LXC containers +// - direct-host - Direct host enrollment +// Future types: +// - vmware-esxi - VMware ESXi VMs +// - docker - Docker containers +// - kubernetes - Kubernetes pods -// Serve the Proxmox LXC enrollment script with credentials injected -router.get("/proxmox-lxc", async (req, res) => { +// Serve auto-enrollment scripts with credentials injected +router.get("/script", async (req, res) => { try { - // Get token from query params + // Get parameters from query params const token_key = req.query.token_key; const token_secret = req.query.token_secret; + const script_type = req.query.type; if (!token_key || !token_secret) { return res @@ -501,6 +504,29 @@ router.get("/proxmox-lxc", async (req, res) => { .json({ error: "Token key and secret required as query parameters" }); } + if (!script_type) { + return res + .status(400) + .json({ + error: + "Script type required as query parameter (e.g., ?type=proxmox-lxc or ?type=direct-host)", + }); + } + + // Map script types to script file paths + const scriptMap = { + "proxmox-lxc": "proxmox_auto_enroll.sh", + "direct-host": "direct_host_auto_enroll.sh", + }; + + if (!scriptMap[script_type]) { + return res + .status(400) + .json({ + error: `Invalid script type: ${script_type}. Supported types: ${Object.keys(scriptMap).join(", ")}`, + }); + } + // Validate token const token = await prisma.auto_enrollment_tokens.findUnique({ where: { token_key: token_key }, @@ -526,13 +552,15 @@ router.get("/proxmox-lxc", async (req, res) => { const script_path = path.join( __dirname, - "../../../agents/proxmox_auto_enroll.sh", + `../../../agents/${scriptMap[script_type]}`, ); if (!fs.existsSync(script_path)) { return res .status(404) - .json({ error: "Proxmox enrollment script not found" }); + .json({ + error: `Enrollment script not found: ${scriptMap[script_type]}`, + }); } let script = fs.readFileSync(script_path, "utf8"); @@ -591,11 +619,11 @@ export FORCE_INSTALL="${force_install ? "true" : "false"}" res.setHeader("Content-Type", "text/plain"); res.setHeader( "Content-Disposition", - 'inline; filename="proxmox_auto_enroll.sh"', + `inline; filename="${scriptMap[script_type]}"`, ); res.send(script); } catch (error) { - console.error("Proxmox script serve error:", error); + console.error("Script serve error:", error); res.status(500).json({ error: "Failed to serve enrollment script" }); } }); @@ -609,8 +637,11 @@ router.post( .isLength({ min: 1, max: 255 }) .withMessage("Friendly name is required"), body("machine_id") + .optional() .isLength({ min: 1, max: 255 }) - .withMessage("Machine ID is required"), + .withMessage( + "Machine ID must be between 1 and 255 characters if provided", + ), body("metadata").optional().isObject(), ], async (req, res) => { @@ -626,24 +657,7 @@ router.post( const api_id = `patchmon_${crypto.randomBytes(8).toString("hex")}`; const api_key = crypto.randomBytes(32).toString("hex"); - // Check if host already exists by machine_id (not hostname) - const existing_host = await prisma.hosts.findUnique({ - where: { machine_id }, - }); - - if (existing_host) { - return res.status(409).json({ - error: "Host already exists", - host_id: existing_host.id, - api_id: existing_host.api_id, - machine_id: existing_host.machine_id, - friendly_name: existing_host.friendly_name, - message: - "This machine is already enrolled in PatchMon (matched by machine ID)", - }); - } - - // Create host + // Create host (no duplicate check - using config.yml checking instead) const host = await prisma.hosts.create({ data: { id: uuidv4(), @@ -760,30 +774,7 @@ router.post( try { const { friendly_name, machine_id } = host_data; - if (!machine_id) { - results.failed.push({ - friendly_name, - error: "Machine ID is required", - }); - continue; - } - - // Check if host already exists by machine_id - const existing_host = await prisma.hosts.findUnique({ - where: { machine_id }, - }); - - if (existing_host) { - results.skipped.push({ - friendly_name, - machine_id, - reason: "Machine already enrolled", - api_id: existing_host.api_id, - }); - continue; - } - - // Generate credentials + // Generate credentials (no duplicate check - using config.yml checking instead) const api_id = `patchmon_${crypto.randomBytes(8).toString("hex")}`; const api_key = crypto.randomBytes(32).toString("hex"); diff --git a/backend/src/routes/hostRoutes.js b/backend/src/routes/hostRoutes.js index a1b2938..6e09e21 100644 --- a/backend/src/routes/hostRoutes.js +++ b/backend/src/routes/hostRoutes.js @@ -1708,47 +1708,7 @@ ${archExport} } }); -// Check if machine_id already exists (requires auth) -router.post("/check-machine-id", validateApiCredentials, async (req, res) => { - try { - const { machine_id } = req.body; - - if (!machine_id) { - return res.status(400).json({ - error: "machine_id is required", - }); - } - - // Check if a host with this machine_id exists - const existing_host = await prisma.hosts.findUnique({ - where: { machine_id }, - select: { - id: true, - friendly_name: true, - machine_id: true, - api_id: true, - status: true, - created_at: true, - }, - }); - - if (existing_host) { - return res.status(200).json({ - exists: true, - host: existing_host, - message: "This machine is already enrolled", - }); - } - - return res.status(200).json({ - exists: false, - message: "Machine not yet enrolled", - }); - } catch (error) { - console.error("Error checking machine_id:", error); - res.status(500).json({ error: "Failed to check machine_id" }); - } -}); +// Note: /check-machine-id endpoint removed - using config.yml checking method instead // Serve the removal script (public endpoint - no authentication required) router.get("/remove", async (_req, res) => { diff --git a/frontend/src/pages/settings/Integrations.jsx b/frontend/src/pages/settings/Integrations.jsx index ce37550..87f0220 100644 --- a/frontend/src/pages/settings/Integrations.jsx +++ b/frontend/src/pages/settings/Integrations.jsx @@ -50,9 +50,14 @@ const Integrations = () => { const [copy_success, setCopySuccess] = useState({}); - // Helper function to build Proxmox enrollment URL with optional force flag + // Helper functions to build enrollment URLs with optional force flag const getProxmoxUrl = () => { - const baseUrl = `${server_url}/api/v1/auto-enrollment/proxmox-lxc?token_key=${new_token.token_key}&token_secret=${new_token.token_secret}`; + const baseUrl = `${server_url}/api/v1/auto-enrollment/script?type=proxmox-lxc&token_key=${new_token.token_key}&token_secret=${new_token.token_secret}`; + return force_proxmox_install ? `${baseUrl}&force=true` : baseUrl; + }; + + const getDirectHostUrl = () => { + const baseUrl = `${server_url}/api/v1/auto-enrollment/script?type=direct-host&token_key=${new_token.token_key}&token_secret=${new_token.token_secret}`; return force_proxmox_install ? `${baseUrl}&force=true` : baseUrl; }; @@ -1578,6 +1583,54 @@ const Integrations = () => { )} + {(new_token.metadata?.integration_type === "proxmox-lxc" || + usage_type === "proxmox-lxc") && ( +
+
+ Direct Host Auto-Enrollment Command +
+

+ Run this command on individual hosts to enroll them + directly: +

+ +
+ + +
+

+ 💡 Run this on individual hosts for easy enrollment + without Proxmox. +

+
+ )} + {(new_token.metadata?.integration_type === "gethomepage" || activeTab === "gethomepage") && (