Compare commits

...

4 Commits

Author SHA1 Message Date
Muhammad Ibrahim
3f6466c80a added the migration file 2025-11-10 22:00:02 +00:00
Muhammad Ibrahim
d1069a8bd0 api endpoint and scopes created 2025-11-10 20:34:03 +00:00
Muhammad Ibrahim
bedcd1ac73 added api scope creator 2025-11-10 20:32:40 +00:00
Muhammad Ibrahim
f0b028cb77 alpine support on the agent installation script 2025-11-08 22:00:34 +00:00
10 changed files with 1253 additions and 181 deletions

View File

@@ -1,7 +1,32 @@
#!/bin/bash #!/bin/sh
# PatchMon Agent Installation Script
# This script requires bash for full functionality
# Usage: curl -s {PATCHMON_URL}/api/v1/hosts/install -H "X-API-ID: {API_ID}" -H "X-API-KEY: {API_KEY}" | sh
# Check if bash is available, if not try to install it (for Alpine Linux)
if ! command -v bash >/dev/null 2>&1; then
if command -v apk >/dev/null 2>&1; then
echo "Installing bash for script compatibility..."
apk add --no-cache bash >/dev/null 2>&1 || true
fi
fi
# If bash is available and we're not already running in bash, switch to bash
# When piped, we can't re-execute easily, so we'll continue with sh
# but ensure bash is available for bash-specific features
if command -v bash >/dev/null 2>&1 && [ -z "${BASH_VERSION:-}" ]; then
# Check if we're being piped (stdin is not a terminal)
if [ -t 0 ]; then
# Direct execution, re-execute with bash
exec bash "$0" "$@"
exit $?
fi
# When piped, we continue with sh but bash is now available
# The script will use bash-specific features which should work if bash is installed
fi
# PatchMon Agent Installation Script # PatchMon Agent Installation Script
# Usage: curl -s {PATCHMON_URL}/api/v1/hosts/install -H "X-API-ID: {API_ID}" -H "X-API-KEY: {API_KEY}" | bash # Usage: curl -s {PATCHMON_URL}/api/v1/hosts/install -H "X-API-ID: {API_ID}" -H "X-API-KEY: {API_KEY}" | sh
set -e set -e
@@ -36,7 +61,7 @@ warning() {
} }
# Check if running as root # Check if running as root
if [[ $EUID -ne 0 ]]; then if [ "$(id -u)" -ne 0 ]; then
error "This script must be run as root (use sudo)" error "This script must be run as root (use sudo)"
fi fi
@@ -45,8 +70,8 @@ verify_datetime() {
info "🕐 Verifying system datetime and timezone..." info "🕐 Verifying system datetime and timezone..."
# Get current system time # Get current system time
local system_time=$(date) system_time=$(date)
local timezone=$(timedatectl show --property=Timezone --value 2>/dev/null || echo "Unknown") timezone=$(timedatectl show --property=Timezone --value 2>/dev/null || echo "Unknown")
# Display current datetime info # Display current datetime info
echo "" echo ""
@@ -56,14 +81,17 @@ verify_datetime() {
echo "" echo ""
# Check if we can read from stdin (interactive terminal) # Check if we can read from stdin (interactive terminal)
if [[ -t 0 ]]; then if [ -t 0 ]; then
# Interactive terminal - ask user # Interactive terminal - ask user
read -p "Does this date/time look correct to you? (y/N): " -r response printf "Does this date/time look correct to you? (y/N): "
if [[ "$response" =~ ^[Yy]$ ]]; then read -r response
success "✅ Date/time verification passed" case "$response" in
echo "" [Yy]*)
return 0 success "✅ Date/time verification passed"
else echo ""
return 0
;;
*)
echo "" echo ""
echo -e "${RED}❌ Date/time verification failed${NC}" echo -e "${RED}❌ Date/time verification failed${NC}"
echo "" echo ""
@@ -72,9 +100,10 @@ verify_datetime() {
echo " sudo timedatectl set-timezone 'America/New_York' # or your timezone" echo " sudo timedatectl set-timezone 'America/New_York' # or your timezone"
echo " sudo timedatectl list-timezones # to see available timezones" echo " sudo timedatectl list-timezones # to see available timezones"
echo "" echo ""
echo -e "${BLUE} After fixing the date/time, re-run this installation script.${NC}" echo -e "${BLUE} After fixing the date/time, re-run this installation script.${NC}"
error "Installation cancelled - please fix date/time and re-run" error "Installation cancelled - please fix date/time and re-run"
fi ;;
esac
else else
# Non-interactive (piped from curl) - show warning and continue # Non-interactive (piped from curl) - show warning and continue
echo -e "${YELLOW}⚠️ Non-interactive installation detected${NC}" echo -e "${YELLOW}⚠️ Non-interactive installation detected${NC}"
@@ -121,9 +150,9 @@ cleanup_old_files
# Generate or retrieve machine ID # Generate or retrieve machine ID
get_machine_id() { get_machine_id() {
# Try multiple sources for machine ID # Try multiple sources for machine ID
if [[ -f /etc/machine-id ]]; then if [ -f /etc/machine-id ]; then
cat /etc/machine-id cat /etc/machine-id
elif [[ -f /var/lib/dbus/machine-id ]]; then elif [ -f /var/lib/dbus/machine-id ]; then
cat /var/lib/dbus/machine-id cat /var/lib/dbus/machine-id
else else
# Fallback: generate from hardware info (less ideal but works) # Fallback: generate from hardware info (less ideal but works)
@@ -132,12 +161,12 @@ get_machine_id() {
} }
# Parse arguments from environment (passed via HTTP headers) # Parse arguments from environment (passed via HTTP headers)
if [[ -z "$PATCHMON_URL" ]] || [[ -z "$API_ID" ]] || [[ -z "$API_KEY" ]]; then if [ -z "$PATCHMON_URL" ] || [ -z "$API_ID" ] || [ -z "$API_KEY" ]; then
error "Missing required parameters. This script should be called via the PatchMon web interface." error "Missing required parameters. This script should be called via the PatchMon web interface."
fi fi
# Auto-detect architecture if not explicitly set # Auto-detect architecture if not explicitly set
if [[ -z "$ARCHITECTURE" ]]; then if [ -z "$ARCHITECTURE" ]; then
arch_raw=$(uname -m 2>/dev/null || echo "unknown") arch_raw=$(uname -m 2>/dev/null || echo "unknown")
# Map architecture to supported values # Map architecture to supported values
@@ -162,13 +191,16 @@ if [[ -z "$ARCHITECTURE" ]]; then
fi fi
# Validate architecture # Validate architecture
if [[ "$ARCHITECTURE" != "amd64" && "$ARCHITECTURE" != "386" && "$ARCHITECTURE" != "arm64" && "$ARCHITECTURE" != "arm" ]]; then if [ "$ARCHITECTURE" != "amd64" ] && [ "$ARCHITECTURE" != "386" ] && [ "$ARCHITECTURE" != "arm64" ] && [ "$ARCHITECTURE" != "arm" ]; then
error "Invalid architecture '$ARCHITECTURE'. Must be one of: amd64, 386, arm64, arm" error "Invalid architecture '$ARCHITECTURE'. Must be one of: amd64, 386, arm64, arm"
fi fi
# Check if --force flag is set (for bypassing broken packages) # Check if --force flag is set (for bypassing broken packages)
FORCE_INSTALL="${FORCE_INSTALL:-false}" FORCE_INSTALL="${FORCE_INSTALL:-false}"
if [[ "$*" == *"--force"* ]] || [[ "$FORCE_INSTALL" == "true" ]]; then case "$*" in
*"--force"*) FORCE_INSTALL="true" ;;
esac
if [ "$FORCE_INSTALL" = "true" ]; then
FORCE_INSTALL="true" FORCE_INSTALL="true"
warning "⚠️ Force mode enabled - will bypass broken packages" warning "⚠️ Force mode enabled - will bypass broken packages"
fi fi
@@ -224,7 +256,7 @@ install_apt_packages() {
# Build apt-get command based on force mode # Build apt-get command based on force mode
local apt_cmd="apt-get install ${missing_packages[*]} -y" local apt_cmd="apt-get install ${missing_packages[*]} -y"
if [[ "$FORCE_INSTALL" == "true" ]]; then if [ "$FORCE_INSTALL" = "true" ]; then
info "Using force mode - bypassing broken packages..." info "Using force mode - bypassing broken packages..."
apt_cmd="$apt_cmd -o APT::Get::Fix-Broken=false -o DPkg::Options::=\"--force-confold\" -o DPkg::Options::=\"--force-confdef\"" apt_cmd="$apt_cmd -o APT::Get::Fix-Broken=false -o DPkg::Options::=\"--force-confold\" -o DPkg::Options::=\"--force-confdef\""
fi fi
@@ -240,7 +272,7 @@ install_apt_packages() {
local all_ok=true local all_ok=true
for pkg in "${packages[@]}"; do for pkg in "${packages[@]}"; do
if ! command_exists "$pkg"; then if ! command_exists "$pkg"; then
if [[ "$FORCE_INSTALL" == "true" ]]; then if [ "$FORCE_INSTALL" = "true" ]; then
error "Critical dependency '$pkg' is not available even with --force. Please install manually." error "Critical dependency '$pkg' is not available even with --force. Please install manually."
else else
error "Critical dependency '$pkg' is not available. Try again with --force flag or install manually: apt-get install $pkg" error "Critical dependency '$pkg' is not available. Try again with --force flag or install manually: apt-get install $pkg"
@@ -279,7 +311,7 @@ install_yum_dnf_packages() {
info "Need to install: ${missing_packages[*]}" info "Need to install: ${missing_packages[*]}"
if [[ "$pkg_manager" == "yum" ]]; then if [ "$pkg_manager" = "yum" ]; then
yum install -y "${missing_packages[@]}" yum install -y "${missing_packages[@]}"
else else
dnf install -y "${missing_packages[@]}" dnf install -y "${missing_packages[@]}"
@@ -365,7 +397,7 @@ install_apk_packages() {
local all_ok=true local all_ok=true
for pkg in "${packages[@]}"; do for pkg in "${packages[@]}"; do
if ! command_exists "$pkg"; then if ! command_exists "$pkg"; then
if [[ "$FORCE_INSTALL" == "true" ]]; then if [ "$FORCE_INSTALL" = "true" ]; then
error "Critical dependency '$pkg' is not available even with --force. Please install manually." error "Critical dependency '$pkg' is not available even with --force. Please install manually."
else else
error "Critical dependency '$pkg' is not available. Try again with --force flag or install manually: apk add $pkg" error "Critical dependency '$pkg' is not available. Try again with --force flag or install manually: apk add $pkg"
@@ -391,7 +423,7 @@ if command -v apt-get >/dev/null 2>&1; then
# Check for broken packages # Check for broken packages
if dpkg -l | grep -q "^iH\|^iF" 2>/dev/null; then if dpkg -l | grep -q "^iH\|^iF" 2>/dev/null; then
if [[ "$FORCE_INSTALL" == "true" ]]; then if [ "$FORCE_INSTALL" = "true" ]; then
warning "Detected broken packages on system - force mode will work around them" warning "Detected broken packages on system - force mode will work around them"
else else
warning "⚠️ Broken packages detected on system" warning "⚠️ Broken packages detected on system"
@@ -446,7 +478,7 @@ echo ""
info "📁 Setting up configuration directory..." info "📁 Setting up configuration directory..."
# Check if configuration directory already exists # Check if configuration directory already exists
if [[ -d "/etc/patchmon" ]]; then if [ -d "/etc/patchmon" ]; then
warning "⚠️ Configuration directory already exists at /etc/patchmon" warning "⚠️ Configuration directory already exists at /etc/patchmon"
warning "⚠️ Preserving existing configuration files" warning "⚠️ Preserving existing configuration files"
@@ -463,8 +495,8 @@ fi
# Check if agent is already configured and working (before we overwrite anything) # Check if agent is already configured and working (before we overwrite anything)
info "🔍 Checking if agent is already configured..." info "🔍 Checking if agent is already configured..."
if [[ -f /etc/patchmon/config.yml ]] && [[ -f /etc/patchmon/credentials.yml ]]; then if [ -f /etc/patchmon/config.yml ] && [ -f /etc/patchmon/credentials.yml ]; then
if [[ -f /usr/local/bin/patchmon-agent ]]; then if [ -f /usr/local/bin/patchmon-agent ]; then
info "📋 Found existing agent configuration" info "📋 Found existing agent configuration"
info "🧪 Testing existing configuration with ping..." info "🧪 Testing existing configuration with ping..."
@@ -495,7 +527,7 @@ fi
info "🔐 Creating configuration files..." info "🔐 Creating configuration files..."
# Check if config file already exists # Check if config file already exists
if [[ -f "/etc/patchmon/config.yml" ]]; then if [ -f "/etc/patchmon/config.yml" ]; then
warning "⚠️ Config file already exists at /etc/patchmon/config.yml" warning "⚠️ Config file already exists at /etc/patchmon/config.yml"
warning "⚠️ Moving existing file out of the way for fresh installation" warning "⚠️ Moving existing file out of the way for fresh installation"
@@ -508,7 +540,7 @@ if [[ -f "/etc/patchmon/config.yml" ]]; then
fi fi
# Check if credentials file already exists # Check if credentials file already exists
if [[ -f "/etc/patchmon/credentials.yml" ]]; then if [ -f "/etc/patchmon/credentials.yml" ]; then
warning "⚠️ Credentials file already exists at /etc/patchmon/credentials.yml" warning "⚠️ Credentials file already exists at /etc/patchmon/credentials.yml"
warning "⚠️ Moving existing file out of the way for fresh installation" warning "⚠️ Moving existing file out of the way for fresh installation"
@@ -521,7 +553,7 @@ if [[ -f "/etc/patchmon/credentials.yml" ]]; then
fi fi
# Clean up old credentials file if it exists (from previous installations) # Clean up old credentials file if it exists (from previous installations)
if [[ -f "/etc/patchmon/credentials" ]]; then if [ -f "/etc/patchmon/credentials" ]; then
warning "⚠️ Found old credentials file, removing it..." warning "⚠️ Found old credentials file, removing it..."
rm -f /etc/patchmon/credentials rm -f /etc/patchmon/credentials
info "📋 Removed old credentials file" info "📋 Removed old credentials file"
@@ -557,7 +589,7 @@ info "📥 Downloading PatchMon agent binary..."
BINARY_NAME="patchmon-agent-linux-${ARCHITECTURE}" BINARY_NAME="patchmon-agent-linux-${ARCHITECTURE}"
# Check if agent binary already exists # Check if agent binary already exists
if [[ -f "/usr/local/bin/patchmon-agent" ]]; then if [ -f "/usr/local/bin/patchmon-agent" ]; then
warning "⚠️ Agent binary already exists at /usr/local/bin/patchmon-agent" warning "⚠️ Agent binary already exists at /usr/local/bin/patchmon-agent"
warning "⚠️ Moving existing file out of the way for fresh installation" warning "⚠️ Moving existing file out of the way for fresh installation"
@@ -570,7 +602,7 @@ if [[ -f "/usr/local/bin/patchmon-agent" ]]; then
fi fi
# Clean up old shell script if it exists (from previous installations) # Clean up old shell script if it exists (from previous installations)
if [[ -f "/usr/local/bin/patchmon-agent.sh" ]]; then if [ -f "/usr/local/bin/patchmon-agent.sh" ]; then
warning "⚠️ Found old shell script agent, removing it..." warning "⚠️ Found old shell script agent, removing it..."
rm -f /usr/local/bin/patchmon-agent.sh rm -f /usr/local/bin/patchmon-agent.sh
info "📋 Removed old shell script agent" info "📋 Removed old shell script agent"
@@ -596,7 +628,7 @@ info "📁 Setting up log directory..."
mkdir -p /etc/patchmon/logs mkdir -p /etc/patchmon/logs
# Handle existing log files # Handle existing log files
if [[ -f "/etc/patchmon/logs/patchmon-agent.log" ]]; then if [ -f "/etc/patchmon/logs/patchmon-agent.log" ]; then
warning "⚠️ Existing log file found at /etc/patchmon/logs/patchmon-agent.log" warning "⚠️ Existing log file found at /etc/patchmon/logs/patchmon-agent.log"
warning "⚠️ Rotating log file for fresh start" warning "⚠️ Rotating log file for fresh start"
@@ -613,23 +645,26 @@ else
error "❌ Failed to validate API credentials or reach server" error "❌ Failed to validate API credentials or reach server"
fi fi
# Step 5: Setup systemd service for WebSocket connection # Step 5: Setup service for WebSocket connection
# Note: The service will automatically send an initial report on startup (see serve.go) # Note: The service will automatically send an initial report on startup (see serve.go)
info "🔧 Setting up systemd service..." # Detect init system and create appropriate service
if command -v systemctl >/dev/null 2>&1; then
# Stop and disable existing service if it exists # Systemd is available
if systemctl is-active --quiet patchmon-agent.service 2>/dev/null; then info "🔧 Setting up systemd service..."
warning "⚠️ Stopping existing PatchMon agent service..."
systemctl stop patchmon-agent.service # Stop and disable existing service if it exists
fi if systemctl is-active --quiet patchmon-agent.service 2>/dev/null; then
warning "⚠️ Stopping existing PatchMon agent service..."
if systemctl is-enabled --quiet patchmon-agent.service 2>/dev/null; then systemctl stop patchmon-agent.service
warning "⚠️ Disabling existing PatchMon agent service..." fi
systemctl disable patchmon-agent.service
fi if systemctl is-enabled --quiet patchmon-agent.service 2>/dev/null; then
warning "⚠️ Disabling existing PatchMon agent service..."
# Create systemd service file systemctl disable patchmon-agent.service
cat > /etc/systemd/system/patchmon-agent.service << EOF fi
# Create systemd service file
cat > /etc/systemd/system/patchmon-agent.service << EOF
[Unit] [Unit]
Description=PatchMon Agent Service Description=PatchMon Agent Service
After=network.target After=network.target
@@ -651,25 +686,105 @@ SyslogIdentifier=patchmon-agent
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
EOF EOF
# Clean up old crontab entries if they exist (from previous installations)
if crontab -l 2>/dev/null | grep -q "patchmon-agent"; then
warning "⚠️ Found old crontab entries, removing them..."
crontab -l 2>/dev/null | grep -v "patchmon-agent" | crontab -
info "📋 Removed old crontab entries"
fi
# Reload systemd and enable/start the service
systemctl daemon-reload
systemctl enable patchmon-agent.service
systemctl start patchmon-agent.service
# Check if service started successfully
if systemctl is-active --quiet patchmon-agent.service; then
success "✅ PatchMon Agent service started successfully"
info "🔗 WebSocket connection established"
else
warning "⚠️ Service may have failed to start. Check status with: systemctl status patchmon-agent"
fi
SERVICE_TYPE="systemd"
elif [ -d /etc/init.d ] && command -v rc-service >/dev/null 2>&1; then
# OpenRC is available (Alpine Linux)
info "🔧 Setting up OpenRC service..."
# Stop and disable existing service if it exists
if rc-service patchmon-agent status >/dev/null 2>&1; then
warning "⚠️ Stopping existing PatchMon agent service..."
rc-service patchmon-agent stop
fi
if rc-update show default 2>/dev/null | grep -q "patchmon-agent"; then
warning "⚠️ Disabling existing PatchMon agent service..."
rc-update del patchmon-agent default
fi
# Create OpenRC service file
cat > /etc/init.d/patchmon-agent << 'EOF'
#!/sbin/openrc-run
# Clean up old crontab entries if they exist (from previous installations) name="patchmon-agent"
if crontab -l 2>/dev/null | grep -q "patchmon-agent"; then description="PatchMon Agent Service"
warning "⚠️ Found old crontab entries, removing them..." command="/usr/local/bin/patchmon-agent"
crontab -l 2>/dev/null | grep -v "patchmon-agent" | crontab - command_args="serve"
info "📋 Removed old crontab entries" command_user="root"
fi pidfile="/var/run/patchmon-agent.pid"
command_background="yes"
working_dir="/etc/patchmon"
# Reload systemd and enable/start the service depend() {
systemctl daemon-reload need net
systemctl enable patchmon-agent.service after net
systemctl start patchmon-agent.service }
EOF
# Check if service started successfully
if systemctl is-active --quiet patchmon-agent.service; then chmod +x /etc/init.d/patchmon-agent
success "✅ PatchMon Agent service started successfully"
info "🔗 WebSocket connection established" # Clean up old crontab entries if they exist (from previous installations)
if crontab -l 2>/dev/null | grep -q "patchmon-agent"; then
warning "⚠️ Found old crontab entries, removing them..."
crontab -l 2>/dev/null | grep -v "patchmon-agent" | crontab -
info "📋 Removed old crontab entries"
fi
# Enable and start the service
rc-update add patchmon-agent default
rc-service patchmon-agent start
# Check if service started successfully
if rc-service patchmon-agent status >/dev/null 2>&1; then
success "✅ PatchMon Agent service started successfully"
info "🔗 WebSocket connection established"
else
warning "⚠️ Service may have failed to start. Check status with: rc-service patchmon-agent status"
fi
SERVICE_TYPE="openrc"
else else
warning "⚠️ Service may have failed to start. Check status with: systemctl status patchmon-agent" # No init system detected, use crontab as fallback
warning "⚠️ No init system detected (systemd or OpenRC). Using crontab for service management."
# Clean up old crontab entries if they exist
if crontab -l 2>/dev/null | grep -q "patchmon-agent"; then
warning "⚠️ Found old crontab entries, removing them..."
crontab -l 2>/dev/null | grep -v "patchmon-agent" | crontab -
info "📋 Removed old crontab entries"
fi
# Add crontab entry to run the agent
(crontab -l 2>/dev/null; echo "@reboot /usr/local/bin/patchmon-agent serve >/dev/null 2>&1") | crontab -
info "📋 Added crontab entry for PatchMon agent"
# Start the agent manually
/usr/local/bin/patchmon-agent serve >/dev/null 2>&1 &
success "✅ PatchMon Agent started in background"
info "🔗 WebSocket connection established"
SERVICE_TYPE="crontab"
fi fi
# Installation complete # Installation complete
@@ -680,14 +795,20 @@ echo " • Configuration directory: /etc/patchmon"
echo " • Agent binary installed: /usr/local/bin/patchmon-agent" echo " • Agent binary installed: /usr/local/bin/patchmon-agent"
echo " • Architecture: $ARCHITECTURE" echo " • Architecture: $ARCHITECTURE"
echo " • Dependencies installed: jq, curl, bc" echo " • Dependencies installed: jq, curl, bc"
echo " • Systemd service configured and running" if [ "$SERVICE_TYPE" = "systemd" ]; then
echo " • Systemd service configured and running"
elif [ "$SERVICE_TYPE" = "openrc" ]; then
echo " • OpenRC service configured and running"
else
echo " • Service configured via crontab"
fi
echo " • API credentials configured and tested" echo " • API credentials configured and tested"
echo " • WebSocket connection established" echo " • WebSocket connection established"
echo " • Logs directory: /etc/patchmon/logs" echo " • Logs directory: /etc/patchmon/logs"
# Check for moved files and show them # Check for moved files and show them
MOVED_FILES=$(ls /etc/patchmon/credentials.yml.backup.* /etc/patchmon/config.yml.backup.* /usr/local/bin/patchmon-agent.backup.* /etc/patchmon/logs/patchmon-agent.log.old.* /usr/local/bin/patchmon-agent.sh.backup.* /etc/patchmon/credentials.backup.* 2>/dev/null || true) MOVED_FILES=$(ls /etc/patchmon/credentials.yml.backup.* /etc/patchmon/config.yml.backup.* /usr/local/bin/patchmon-agent.backup.* /etc/patchmon/logs/patchmon-agent.log.old.* /usr/local/bin/patchmon-agent.sh.backup.* /etc/patchmon/credentials.backup.* 2>/dev/null || true)
if [[ -n "$MOVED_FILES" ]]; then if [ -n "$MOVED_FILES" ]; then
echo "" echo ""
echo -e "${YELLOW}📋 Files Moved for Fresh Installation:${NC}" echo -e "${YELLOW}📋 Files Moved for Fresh Installation:${NC}"
echo "$MOVED_FILES" | while read -r moved_file; do echo "$MOVED_FILES" | while read -r moved_file; do
@@ -702,8 +823,17 @@ echo -e "${BLUE}🔧 Management Commands:${NC}"
echo " • Test connection: /usr/local/bin/patchmon-agent ping" echo " • Test connection: /usr/local/bin/patchmon-agent ping"
echo " • Manual report: /usr/local/bin/patchmon-agent report" echo " • Manual report: /usr/local/bin/patchmon-agent report"
echo " • Check status: /usr/local/bin/patchmon-agent diagnostics" echo " • Check status: /usr/local/bin/patchmon-agent diagnostics"
echo " • Service status: systemctl status patchmon-agent" if [ "$SERVICE_TYPE" = "systemd" ]; then
echo " • Service logs: journalctl -u patchmon-agent -f" echo " • Service status: systemctl status patchmon-agent"
echo " • Restart service: systemctl restart patchmon-agent" echo " • Service logs: journalctl -u patchmon-agent -f"
echo " • Restart service: systemctl restart patchmon-agent"
elif [ "$SERVICE_TYPE" = "openrc" ]; then
echo " • Service status: rc-service patchmon-agent status"
echo " • Service logs: tail -f /etc/patchmon/logs/patchmon-agent.log"
echo " • Restart service: rc-service patchmon-agent restart"
else
echo " • Service logs: tail -f /etc/patchmon/logs/patchmon-agent.log"
echo " • Restart service: pkill -f 'patchmon-agent serve' && /usr/local/bin/patchmon-agent serve &"
fi
echo "" echo ""
success "✅ Your system is now being monitored by PatchMon!" success "✅ Your system is now being monitored by PatchMon!"

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "auto_enrollment_tokens" ADD COLUMN "scopes" JSONB;

View File

@@ -288,6 +288,7 @@ model auto_enrollment_tokens {
last_used_at DateTime? last_used_at DateTime?
expires_at DateTime? expires_at DateTime?
metadata Json? metadata Json?
scopes Json?
users users? @relation(fields: [created_by_user_id], references: [id], onDelete: SetNull) 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) host_groups host_groups? @relation(fields: [default_host_group_id], references: [id], onDelete: SetNull)

View File

@@ -0,0 +1,113 @@
const { getPrismaClient } = require("../config/prisma");
const bcrypt = require("bcryptjs");
const prisma = getPrismaClient();
/**
* Middleware factory to authenticate API tokens using Basic Auth
* @param {string} integrationType - The expected integration type (e.g., "api", "gethomepage")
* @returns {Function} Express middleware function
*/
const authenticateApiToken = (integrationType) => {
return async (req, res, next) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Basic ")) {
return res
.status(401)
.json({ error: "Missing or invalid authorization header" });
}
// Decode base64 credentials
const base64Credentials = authHeader.split(" ")[1];
const credentials = Buffer.from(base64Credentials, "base64").toString(
"ascii",
);
const [apiKey, apiSecret] = credentials.split(":");
if (!apiKey || !apiSecret) {
return res.status(401).json({ error: "Invalid credentials format" });
}
// Find the token in database
const token = await prisma.auto_enrollment_tokens.findUnique({
where: { token_key: apiKey },
include: {
users: {
select: {
id: true,
username: true,
role: true,
},
},
},
});
if (!token) {
console.log(`API key not found: ${apiKey}`);
return res.status(401).json({ error: "Invalid API key" });
}
// Check if token is active
if (!token.is_active) {
return res.status(401).json({ error: "API key is disabled" });
}
// Check if token has expired
if (token.expires_at && new Date(token.expires_at) < new Date()) {
return res.status(401).json({ error: "API key has expired" });
}
// Check if token is for the expected integration type
if (token.metadata?.integration_type !== integrationType) {
return res.status(401).json({ error: "Invalid API key type" });
}
// Verify the secret
const isValidSecret = await bcrypt.compare(apiSecret, token.token_secret);
if (!isValidSecret) {
return res.status(401).json({ error: "Invalid API secret" });
}
// Check IP restrictions if any
if (token.allowed_ip_ranges && token.allowed_ip_ranges.length > 0) {
const clientIp = req.ip || req.connection.remoteAddress;
const forwardedFor = req.headers["x-forwarded-for"];
const realIp = req.headers["x-real-ip"];
// Get the actual client IP (considering proxies)
const actualClientIp = forwardedFor
? forwardedFor.split(",")[0].trim()
: realIp || clientIp;
const isAllowedIp = token.allowed_ip_ranges.some((range) => {
// Simple IP range check (can be enhanced for CIDR support)
return actualClientIp.startsWith(range) || actualClientIp === range;
});
if (!isAllowedIp) {
console.log(
`IP validation failed. Client IP: ${actualClientIp}, Allowed ranges: ${token.allowed_ip_ranges.join(", ")}`,
);
return res.status(403).json({ error: "IP address not allowed" });
}
}
// Update last used timestamp
await prisma.auto_enrollment_tokens.update({
where: { id: token.id },
data: { last_used_at: new Date() },
});
// Attach token info to request
req.apiToken = token;
next();
} catch (error) {
console.error("API key authentication error:", error);
res.status(500).json({ error: "Authentication failed" });
}
};
};
module.exports = { authenticateApiToken };

View File

@@ -0,0 +1,76 @@
/**
* Middleware factory to validate API token scopes
* Only applies to tokens with metadata.integration_type === "api"
* @param {string} resource - The resource being accessed (e.g., "host")
* @param {string} action - The action being performed (e.g., "get", "put", "patch", "update", "delete")
* @returns {Function} Express middleware function
*/
const requireApiScope = (resource, action) => {
return async (req, res, next) => {
try {
const token = req.apiToken;
// If no token attached, this should have been caught by auth middleware
if (!token) {
return res.status(401).json({ error: "Unauthorized" });
}
// Only validate scopes for API type tokens
if (token.metadata?.integration_type !== "api") {
// For non-API tokens, skip scope validation
return next();
}
// Check if token has scopes field
if (!token.scopes || typeof token.scopes !== "object") {
console.warn(
`API token ${token.token_key} missing scopes field for ${resource}:${action}`,
);
return res.status(403).json({
error: "Access denied",
message: "This API key does not have the required permissions",
});
}
// Check if resource exists in scopes
if (!token.scopes[resource]) {
console.warn(
`API token ${token.token_key} missing resource ${resource} for ${action}`,
);
return res.status(403).json({
error: "Access denied",
message: `This API key does not have access to ${resource}`,
});
}
// Check if action exists in resource scopes
if (!Array.isArray(token.scopes[resource])) {
console.warn(
`API token ${token.token_key} has invalid scopes structure for ${resource}`,
);
return res.status(403).json({
error: "Access denied",
message: "Invalid API key permissions configuration",
});
}
if (!token.scopes[resource].includes(action)) {
console.warn(
`API token ${token.token_key} missing action ${action} for resource ${resource}`,
);
return res.status(403).json({
error: "Access denied",
message: `This API key does not have permission to ${action} ${resource}`,
});
}
// Scope validation passed
next();
} catch (error) {
console.error("Scope validation error:", error);
res.status(500).json({ error: "Scope validation failed" });
}
};
};
module.exports = { requireApiScope };

View File

@@ -0,0 +1,143 @@
const express = require("express");
const { getPrismaClient } = require("../config/prisma");
const { authenticateApiToken } = require("../middleware/apiAuth");
const { requireApiScope } = require("../middleware/apiScope");
const router = express.Router();
const prisma = getPrismaClient();
// Helper function to check if a string is a valid UUID
const isUUID = (str) => {
const uuidRegex =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
return uuidRegex.test(str);
};
// GET /api/v1/api/hosts - List hosts with IP and groups
router.get(
"/hosts",
authenticateApiToken("api"),
requireApiScope("host", "get"),
async (req, res) => {
try {
const { hostgroup } = req.query;
let whereClause = {};
let filterValues = [];
// Parse hostgroup filter (comma-separated names or UUIDs)
if (hostgroup) {
filterValues = hostgroup.split(",").map((g) => g.trim());
// Separate UUIDs from names
const uuidFilters = [];
const nameFilters = [];
for (const value of filterValues) {
if (isUUID(value)) {
uuidFilters.push(value);
} else {
nameFilters.push(value);
}
}
// Find host group IDs from names
const groupIds = [...uuidFilters];
if (nameFilters.length > 0) {
const groups = await prisma.host_groups.findMany({
where: {
name: {
in: nameFilters,
},
},
select: {
id: true,
name: true,
},
});
// Add found group IDs
groupIds.push(...groups.map((g) => g.id));
// Check if any name filters didn't match
const foundNames = groups.map((g) => g.name);
const notFoundNames = nameFilters.filter(
(name) => !foundNames.includes(name),
);
if (notFoundNames.length > 0) {
console.warn(`Host groups not found: ${notFoundNames.join(", ")}`);
}
}
// Filter hosts by group memberships
if (groupIds.length > 0) {
whereClause = {
host_group_memberships: {
some: {
host_group_id: {
in: groupIds,
},
},
},
};
} else {
// No valid groups found, return empty result
return res.json({
hosts: [],
total: 0,
filtered_by_groups: filterValues,
});
}
}
// Query hosts with groups
const hosts = await prisma.hosts.findMany({
where: whereClause,
select: {
id: true,
friendly_name: true,
hostname: true,
ip: true,
host_group_memberships: {
include: {
host_groups: {
select: {
id: true,
name: true,
},
},
},
},
},
orderBy: {
friendly_name: "asc",
},
});
// Format response
const formattedHosts = hosts.map((host) => ({
id: host.id,
friendly_name: host.friendly_name,
hostname: host.hostname,
ip: host.ip,
host_groups: host.host_group_memberships.map((membership) => ({
id: membership.host_groups.id,
name: membership.host_groups.name,
})),
}));
res.json({
hosts: formattedHosts,
total: formattedHosts.length,
filtered_by_groups: filterValues.length > 0 ? filterValues : undefined,
});
} catch (error) {
console.error("Error fetching hosts:", error);
res.status(500).json({ error: "Failed to fetch hosts" });
}
},
);
module.exports = router;

View File

@@ -125,6 +125,10 @@ router.post(
.optional({ nullable: true, checkFalsy: true }) .optional({ nullable: true, checkFalsy: true })
.isISO8601() .isISO8601()
.withMessage("Invalid date format"), .withMessage("Invalid date format"),
body("scopes")
.optional()
.isObject()
.withMessage("Scopes must be an object"),
], ],
async (req, res) => { async (req, res) => {
try { try {
@@ -140,6 +144,7 @@ router.post(
default_host_group_id, default_host_group_id,
expires_at, expires_at,
metadata = {}, metadata = {},
scopes,
} = req.body; } = req.body;
// Validate host group if provided // Validate host group if provided
@@ -153,6 +158,32 @@ router.post(
} }
} }
// Validate scopes for API tokens
if (metadata.integration_type === "api" && scopes) {
// Validate scopes structure
if (typeof scopes !== "object" || scopes === null) {
return res.status(400).json({ error: "Scopes must be an object" });
}
// Validate each resource in scopes
for (const [resource, actions] of Object.entries(scopes)) {
if (!Array.isArray(actions)) {
return res.status(400).json({
error: `Scopes for resource "${resource}" must be an array of actions`,
});
}
// Validate action names
for (const action of actions) {
if (typeof action !== "string") {
return res.status(400).json({
error: `All actions in scopes must be strings`,
});
}
}
}
}
const { token_key, token_secret } = generate_auto_enrollment_token(); const { token_key, token_secret } = generate_auto_enrollment_token();
const hashed_secret = await bcrypt.hash(token_secret, 10); const hashed_secret = await bcrypt.hash(token_secret, 10);
@@ -168,6 +199,7 @@ router.post(
default_host_group_id: default_host_group_id || null, default_host_group_id: default_host_group_id || null,
expires_at: expires_at ? new Date(expires_at) : null, expires_at: expires_at ? new Date(expires_at) : null,
metadata: { integration_type: "proxmox-lxc", ...metadata }, metadata: { integration_type: "proxmox-lxc", ...metadata },
scopes: metadata.integration_type === "api" ? scopes || null : null,
updated_at: new Date(), updated_at: new Date(),
}, },
include: { include: {
@@ -201,6 +233,7 @@ router.post(
default_host_group: token.host_groups, default_host_group: token.host_groups,
created_by: token.users, created_by: token.users,
expires_at: token.expires_at, expires_at: token.expires_at,
scopes: token.scopes,
}, },
warning: "⚠️ Save the token_secret now - it cannot be retrieved later!", warning: "⚠️ Save the token_secret now - it cannot be retrieved later!",
}); });
@@ -232,6 +265,7 @@ router.get(
created_at: true, created_at: true,
default_host_group_id: true, default_host_group_id: true,
metadata: true, metadata: true,
scopes: true,
host_groups: { host_groups: {
select: { select: {
id: true, id: true,
@@ -314,6 +348,10 @@ router.patch(
body("max_hosts_per_day").optional().isInt({ min: 1, max: 1000 }), body("max_hosts_per_day").optional().isInt({ min: 1, max: 1000 }),
body("allowed_ip_ranges").optional().isArray(), body("allowed_ip_ranges").optional().isArray(),
body("expires_at").optional().isISO8601(), body("expires_at").optional().isISO8601(),
body("scopes")
.optional()
.isObject()
.withMessage("Scopes must be an object"),
], ],
async (req, res) => { async (req, res) => {
try { try {
@@ -323,6 +361,16 @@ router.patch(
} }
const { tokenId } = req.params; const { tokenId } = req.params;
// First, get the existing token to check its integration type
const existing_token = await prisma.auto_enrollment_tokens.findUnique({
where: { id: tokenId },
});
if (!existing_token) {
return res.status(404).json({ error: "Token not found" });
}
const update_data = { updated_at: new Date() }; const update_data = { updated_at: new Date() };
if (req.body.is_active !== undefined) if (req.body.is_active !== undefined)
@@ -334,6 +382,41 @@ router.patch(
if (req.body.expires_at !== undefined) if (req.body.expires_at !== undefined)
update_data.expires_at = new Date(req.body.expires_at); update_data.expires_at = new Date(req.body.expires_at);
// Handle scopes updates for API tokens only
if (req.body.scopes !== undefined) {
if (existing_token.metadata?.integration_type === "api") {
// Validate scopes structure
const scopes = req.body.scopes;
if (typeof scopes !== "object" || scopes === null) {
return res.status(400).json({ error: "Scopes must be an object" });
}
// Validate each resource in scopes
for (const [resource, actions] of Object.entries(scopes)) {
if (!Array.isArray(actions)) {
return res.status(400).json({
error: `Scopes for resource "${resource}" must be an array of actions`,
});
}
// Validate action names
for (const action of actions) {
if (typeof action !== "string") {
return res.status(400).json({
error: `All actions in scopes must be strings`,
});
}
}
}
update_data.scopes = scopes;
} else {
return res.status(400).json({
error: "Scopes can only be updated for API integration tokens",
});
}
}
const token = await prisma.auto_enrollment_tokens.update({ const token = await prisma.auto_enrollment_tokens.update({
where: { id: tokenId }, where: { id: tokenId },
data: update_data, data: update_data,

View File

@@ -1,113 +1,12 @@
const express = require("express"); const express = require("express");
const { getPrismaClient } = require("../config/prisma"); const { getPrismaClient } = require("../config/prisma");
const bcrypt = require("bcryptjs"); const { authenticateApiToken } = require("../middleware/apiAuth");
const router = express.Router(); const router = express.Router();
const prisma = getPrismaClient(); const prisma = getPrismaClient();
// Middleware to authenticate API key
const authenticateApiKey = async (req, res, next) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Basic ")) {
return res
.status(401)
.json({ error: "Missing or invalid authorization header" });
}
// Decode base64 credentials
const base64Credentials = authHeader.split(" ")[1];
const credentials = Buffer.from(base64Credentials, "base64").toString(
"ascii",
);
const [apiKey, apiSecret] = credentials.split(":");
if (!apiKey || !apiSecret) {
return res.status(401).json({ error: "Invalid credentials format" });
}
// Find the token in database
const token = await prisma.auto_enrollment_tokens.findUnique({
where: { token_key: apiKey },
include: {
users: {
select: {
id: true,
username: true,
role: true,
},
},
},
});
if (!token) {
console.log(`API key not found: ${apiKey}`);
return res.status(401).json({ error: "Invalid API key" });
}
// Check if token is active
if (!token.is_active) {
return res.status(401).json({ error: "API key is disabled" });
}
// Check if token has expired
if (token.expires_at && new Date(token.expires_at) < new Date()) {
return res.status(401).json({ error: "API key has expired" });
}
// Check if token is for gethomepage integration
if (token.metadata?.integration_type !== "gethomepage") {
return res.status(401).json({ error: "Invalid API key type" });
}
// Verify the secret
const isValidSecret = await bcrypt.compare(apiSecret, token.token_secret);
if (!isValidSecret) {
return res.status(401).json({ error: "Invalid API secret" });
}
// Check IP restrictions if any
if (token.allowed_ip_ranges && token.allowed_ip_ranges.length > 0) {
const clientIp = req.ip || req.connection.remoteAddress;
const forwardedFor = req.headers["x-forwarded-for"];
const realIp = req.headers["x-real-ip"];
// Get the actual client IP (considering proxies)
const actualClientIp = forwardedFor
? forwardedFor.split(",")[0].trim()
: realIp || clientIp;
const isAllowedIp = token.allowed_ip_ranges.some((range) => {
// Simple IP range check (can be enhanced for CIDR support)
return actualClientIp.startsWith(range) || actualClientIp === range;
});
if (!isAllowedIp) {
console.log(
`IP validation failed. Client IP: ${actualClientIp}, Allowed ranges: ${token.allowed_ip_ranges.join(", ")}`,
);
return res.status(403).json({ error: "IP address not allowed" });
}
}
// Update last used timestamp
await prisma.auto_enrollment_tokens.update({
where: { id: token.id },
data: { last_used_at: new Date() },
});
// Attach token info to request
req.apiToken = token;
next();
} catch (error) {
console.error("API key authentication error:", error);
res.status(500).json({ error: "Authentication failed" });
}
};
// Get homepage widget statistics // Get homepage widget statistics
router.get("/stats", authenticateApiKey, async (_req, res) => { router.get("/stats", authenticateApiToken("gethomepage"), async (_req, res) => {
try { try {
// Get total hosts count // Get total hosts count
const totalHosts = await prisma.hosts.count({ const totalHosts = await prisma.hosts.count({
@@ -235,7 +134,7 @@ router.get("/stats", authenticateApiKey, async (_req, res) => {
}); });
// Health check endpoint for the API // Health check endpoint for the API
router.get("/health", authenticateApiKey, async (req, res) => { router.get("/health", authenticateApiToken("gethomepage"), async (req, res) => {
res.json({ res.json({
status: "ok", status: "ok",
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),

View File

@@ -71,6 +71,7 @@ const wsRoutes = require("./routes/wsRoutes");
const agentVersionRoutes = require("./routes/agentVersionRoutes"); const agentVersionRoutes = require("./routes/agentVersionRoutes");
const metricsRoutes = require("./routes/metricsRoutes"); const metricsRoutes = require("./routes/metricsRoutes");
const userPreferencesRoutes = require("./routes/userPreferencesRoutes"); const userPreferencesRoutes = require("./routes/userPreferencesRoutes");
const apiHostsRoutes = require("./routes/apiHostsRoutes");
const { initSettings } = require("./services/settingsService"); const { initSettings } = require("./services/settingsService");
const { queueManager } = require("./services/automation"); const { queueManager } = require("./services/automation");
const { authenticateToken, requireAdmin } = require("./middleware/auth"); const { authenticateToken, requireAdmin } = require("./middleware/auth");
@@ -480,6 +481,7 @@ app.use(`/api/${apiVersion}/ws`, wsRoutes);
app.use(`/api/${apiVersion}/agent`, agentVersionRoutes); app.use(`/api/${apiVersion}/agent`, agentVersionRoutes);
app.use(`/api/${apiVersion}/metrics`, metricsRoutes); app.use(`/api/${apiVersion}/metrics`, metricsRoutes);
app.use(`/api/${apiVersion}/user/preferences`, userPreferencesRoutes); app.use(`/api/${apiVersion}/user/preferences`, userPreferencesRoutes);
app.use(`/api/${apiVersion}/api`, authLimiter, apiHostsRoutes);
// Bull Board - will be populated after queue manager initializes // Bull Board - will be populated after queue manager initializes
let bullBoardRouter = null; let bullBoardRouter = null;

View File

@@ -28,6 +28,8 @@ const Integrations = () => {
const [host_groups, setHostGroups] = useState([]); const [host_groups, setHostGroups] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [show_create_modal, setShowCreateModal] = useState(false); const [show_create_modal, setShowCreateModal] = useState(false);
const [show_edit_modal, setShowEditModal] = useState(false);
const [edit_token, setEditToken] = useState(null);
const [new_token, setNewToken] = useState(null); const [new_token, setNewToken] = useState(null);
const [show_secret, setShowSecret] = useState(false); const [show_secret, setShowSecret] = useState(false);
const [server_url, setServerUrl] = useState(""); const [server_url, setServerUrl] = useState("");
@@ -40,6 +42,9 @@ const Integrations = () => {
default_host_group_id: "", default_host_group_id: "",
allowed_ip_ranges: "", allowed_ip_ranges: "",
expires_at: "", expires_at: "",
scopes: {
host: [],
},
}); });
const [copy_success, setCopySuccess] = useState({}); const [copy_success, setCopySuccess] = useState({});
@@ -54,6 +59,25 @@ const Integrations = () => {
setActiveTab(tabName); setActiveTab(tabName);
}; };
const toggle_scope_action = (resource, action) => {
setFormData((prev) => {
const current_scopes = prev.scopes || { [resource]: [] };
const resource_scopes = current_scopes[resource] || [];
const updated_scopes = resource_scopes.includes(action)
? resource_scopes.filter((a) => a !== action)
: [...resource_scopes, action];
return {
...prev,
scopes: {
...current_scopes,
[resource]: updated_scopes,
},
};
});
};
// biome-ignore lint/correctness/useExhaustiveDependencies: Only run on mount // biome-ignore lint/correctness/useExhaustiveDependencies: Only run on mount
useEffect(() => { useEffect(() => {
load_tokens(); load_tokens();
@@ -96,6 +120,14 @@ const Integrations = () => {
e.preventDefault(); e.preventDefault();
try { try {
// Determine integration type based on active tab
let integration_type = "proxmox-lxc";
if (activeTab === "gethomepage") {
integration_type = "gethomepage";
} else if (activeTab === "api") {
integration_type = "api";
}
const data = { const data = {
token_name: form_data.token_name, token_name: form_data.token_name,
max_hosts_per_day: Number.parseInt(form_data.max_hosts_per_day, 10), max_hosts_per_day: Number.parseInt(form_data.max_hosts_per_day, 10),
@@ -103,8 +135,7 @@ const Integrations = () => {
? form_data.allowed_ip_ranges.split(",").map((ip) => ip.trim()) ? form_data.allowed_ip_ranges.split(",").map((ip) => ip.trim())
: [], : [],
metadata: { metadata: {
integration_type: integration_type: integration_type,
activeTab === "gethomepage" ? "gethomepage" : "proxmox-lxc",
}, },
}; };
@@ -116,6 +147,11 @@ const Integrations = () => {
data.expires_at = form_data.expires_at; data.expires_at = form_data.expires_at;
} }
// Add scopes for API credentials
if (activeTab === "api" && form_data.scopes) {
data.scopes = form_data.scopes;
}
const response = await api.post("/auto-enrollment/tokens", data); const response = await api.post("/auto-enrollment/tokens", data);
setNewToken(response.data.token); setNewToken(response.data.token);
setShowCreateModal(false); setShowCreateModal(false);
@@ -128,6 +164,9 @@ const Integrations = () => {
default_host_group_id: "", default_host_group_id: "",
allowed_ip_ranges: "", allowed_ip_ranges: "",
expires_at: "", expires_at: "",
scopes: {
host: [],
},
}); });
} catch (error) { } catch (error) {
console.error("Failed to create token:", error); console.error("Failed to create token:", error);
@@ -168,6 +207,69 @@ const Integrations = () => {
} }
}; };
const open_edit_modal = (token) => {
setEditToken(token);
setFormData({
token_name: token.token_name,
max_hosts_per_day: token.max_hosts_per_day || 100,
default_host_group_id: token.default_host_group_id || "",
allowed_ip_ranges: token.allowed_ip_ranges?.join(", ") || "",
expires_at: token.expires_at
? new Date(token.expires_at).toISOString().slice(0, 16)
: "",
scopes: token.scopes || { host: [] },
});
setShowEditModal(true);
};
const update_token = async (e) => {
e.preventDefault();
try {
const data = {
allowed_ip_ranges: form_data.allowed_ip_ranges
? form_data.allowed_ip_ranges.split(",").map((ip) => ip.trim())
: [],
};
// Add expiration if provided
if (form_data.expires_at) {
data.expires_at = form_data.expires_at;
}
// Add scopes for API credentials
if (
edit_token?.metadata?.integration_type === "api" &&
form_data.scopes
) {
data.scopes = form_data.scopes;
}
await api.patch(`/auto-enrollment/tokens/${edit_token.id}`, data);
setShowEditModal(false);
setEditToken(null);
load_tokens();
// Reset form
setFormData({
token_name: "",
max_hosts_per_day: 100,
default_host_group_id: "",
allowed_ip_ranges: "",
expires_at: "",
scopes: {
host: [],
},
});
} catch (error) {
console.error("Failed to update token:", error);
const error_message = error.response?.data?.errors
? error.response.data.errors.map((e) => e.msg).join(", ")
: error.response?.data?.error || "Failed to update token";
alert(error_message);
}
};
const copy_to_clipboard = async (text, key) => { const copy_to_clipboard = async (text, key) => {
// Check if Clipboard API is available // Check if Clipboard API is available
if (navigator.clipboard && window.isSecureContext) { if (navigator.clipboard && window.isSecureContext) {
@@ -256,6 +358,17 @@ const Integrations = () => {
> >
GetHomepage GetHomepage
</button> </button>
<button
type="button"
onClick={() => handleTabChange("api")}
className={`px-6 py-3 text-sm font-medium ${
activeTab === "api"
? "text-primary-600 dark:text-primary-400 border-b-2 border-primary-500 bg-primary-50 dark:bg-primary-900/20"
: "text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700/50"
}`}
>
API
</button>
<button <button
type="button" type="button"
onClick={() => handleTabChange("docker")} onClick={() => handleTabChange("docker")}
@@ -736,6 +849,214 @@ const Integrations = () => {
</div> </div>
)} )}
{/* API Tab */}
{activeTab === "api" && (
<div className="space-y-6">
{/* Header with New Credential Button */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<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">
API Credentials
</h3>
<p className="text-sm text-secondary-600 dark:text-secondary-400">
Manage API credentials for programmatic access to
PatchMon data
</p>
</div>
</div>
<button
type="button"
onClick={() => setShowCreateModal(true)}
className="btn-primary flex items-center gap-2"
>
<Plus className="h-4 w-4" />
New Credential
</button>
</div>
{/* API Credentials 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>
) : tokens.filter(
(token) => token.metadata?.integration_type === "api",
).length === 0 ? (
<div className="text-center py-8 text-secondary-600 dark:text-secondary-400">
<p>No API credentials created yet.</p>
<p className="text-sm mt-2">
Create a credential to enable programmatic access to
PatchMon.
</p>
</div>
) : (
<div className="space-y-3">
{tokens
.filter(
(token) => token.metadata?.integration_type === "api",
)
.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-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
API
</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>
{token.scopes && (
<p>
Scopes:{" "}
{Object.entries(token.scopes)
.map(
([resource, actions]) =>
`${resource}: ${Array.isArray(actions) ? actions.join(", ") : actions}`,
)
.join(" | ")}
</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={() => open_edit_modal(token)}
className="px-3 py-1 text-sm rounded bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900 dark:text-blue-300"
>
Edit
</button>
<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>
)}
{/* 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-4">
Using API Credentials
</h3>
<div className="space-y-4 text-sm text-primary-800 dark:text-primary-300">
<p>
API credentials allow you to programmatically access
PatchMon data using Basic Authentication.
</p>
<div>
<p className="font-semibold mb-2">
Example cURL Request:
</p>
<div className="bg-primary-100 dark:bg-primary-900/40 p-3 rounded border border-primary-200 dark:border-primary-700 font-mono text-xs overflow-x-auto">
curl -u "YOUR_API_KEY:YOUR_API_SECRET" \<br />
&nbsp;&nbsp;{server_url}/api/v1/api/hosts
</div>
</div>
<div>
<p className="font-semibold mb-2">
Query Hosts by Group:
</p>
<div className="bg-primary-100 dark:bg-primary-900/40 p-3 rounded border border-primary-200 dark:border-primary-700 font-mono text-xs overflow-x-auto">
curl -u "YOUR_API_KEY:YOUR_API_SECRET" \<br />
&nbsp;&nbsp;"{server_url}
/api/v1/api/hosts?hostgroup=Production,Development"
</div>
</div>
<p className="text-xs">
<strong>💡 Tip:</strong> You can filter by host group
names or UUIDs. Multiple groups can be specified as a
comma-separated list.
</p>
</div>
</div>
</div>
)}
{/* Docker Tab */} {/* Docker Tab */}
{activeTab === "docker" && ( {activeTab === "docker" && (
<div className="space-y-6"> <div className="space-y-6">
@@ -885,7 +1206,9 @@ const Integrations = () => {
<h2 className="text-xl font-bold text-secondary-900 dark:text-white"> <h2 className="text-xl font-bold text-secondary-900 dark:text-white">
{activeTab === "gethomepage" {activeTab === "gethomepage"
? "Create GetHomepage API Key" ? "Create GetHomepage API Key"
: "Create Auto-Enrollment Token"} : activeTab === "api"
? "Create API Credential"
: "Create Auto-Enrollment Token"}
</h2> </h2>
<button <button
type="button" type="button"
@@ -911,7 +1234,9 @@ const Integrations = () => {
placeholder={ placeholder={
activeTab === "gethomepage" activeTab === "gethomepage"
? "e.g., GetHomepage Widget" ? "e.g., GetHomepage Widget"
: "e.g., Proxmox Production" : activeTab === "api"
? "e.g., Ansible Inventory"
: "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" 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"
/> />
@@ -970,6 +1295,56 @@ const Integrations = () => {
</> </>
)} )}
{activeTab === "api" && (
<div className="block">
<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
Scopes *
</span>
<div className="border border-secondary-300 dark:border-secondary-600 rounded-md p-4 bg-secondary-50 dark:bg-secondary-900">
<div className="mb-3">
<p className="text-xs font-semibold text-secondary-700 dark:text-secondary-300 mb-2">
Host Permissions
</p>
<div className="space-y-2">
{["get", "put", "patch", "update", "delete"].map(
(action) => (
<label
key={action}
className="flex items-center gap-2"
>
<input
type="checkbox"
checked={
form_data.scopes?.host?.includes(action) ||
false
}
onChange={() =>
toggle_scope_action("host", action)
}
className="rounded border-secondary-300 dark:border-secondary-600 text-primary-600 focus:ring-primary-500 dark:focus:ring-primary-400"
/>
<span className="text-sm text-secondary-700 dark:text-secondary-300 uppercase">
{action}
</span>
<span className="text-xs text-secondary-500 dark:text-secondary-400">
{action === "get" && "- Read host data"}
{action === "put" && "- Replace host data"}
{action === "patch" && "- Update host data"}
{action === "update" && "- Modify host data"}
{action === "delete" && "- Delete hosts"}
</span>
</label>
),
)}
</div>
</div>
</div>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Select the permissions this API credential should have
</p>
</div>
)}
<label className="block"> <label className="block">
<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1"> <span className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Allowed IP Addresses (Optional) Allowed IP Addresses (Optional)
@@ -1038,7 +1413,9 @@ const Integrations = () => {
<h2 className="text-lg font-bold text-secondary-900 dark:text-white"> <h2 className="text-lg font-bold text-secondary-900 dark:text-white">
{activeTab === "gethomepage" {activeTab === "gethomepage"
? "API Key Created Successfully" ? "API Key Created Successfully"
: "Token Created Successfully"} : activeTab === "api"
? "API Credential Created Successfully"
: "Token Created Successfully"}
</h2> </h2>
</div> </div>
<button <button
@@ -1161,6 +1538,103 @@ const Integrations = () => {
</div> </div>
</div> </div>
{activeTab === "api" && new_token.scopes && (
<div className="mt-4">
<div className="block text-xs font-medium text-secondary-700 dark:text-secondary-300 mb-2">
Granted Scopes
</div>
<div className="bg-secondary-50 dark:bg-secondary-900 border border-secondary-300 dark:border-secondary-600 rounded-md p-3">
{Object.entries(new_token.scopes).map(
([resource, actions]) => (
<div key={resource} className="text-sm">
<span className="font-semibold text-secondary-800 dark:text-secondary-200 capitalize">
{resource}:
</span>{" "}
<span className="text-secondary-600 dark:text-secondary-400">
{Array.isArray(actions)
? actions.join(", ").toUpperCase()
: actions}
</span>
</div>
),
)}
</div>
</div>
)}
{activeTab === "api" && (
<div className="mt-6">
<div className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
Usage Examples
</div>
<div className="space-y-3">
<div>
<p className="text-xs text-secondary-600 dark:text-secondary-400 mb-2">
Basic cURL request:
</p>
<div className="flex items-center gap-2">
<input
type="text"
value={`curl -u "${new_token.token_key}:${new_token.token_secret}" ${server_url}/api/v1/api/hosts`}
readOnly
className="flex-1 px-3 py-2 text-xs 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"
/>
<button
type="button"
onClick={() =>
copy_to_clipboard(
`curl -u "${new_token.token_key}:${new_token.token_secret}" ${server_url}/api/v1/api/hosts`,
"api-curl-basic",
)
}
className="btn-primary p-2"
title="Copy cURL command"
>
{copy_success["api-curl-basic"] ? (
<CheckCircle className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</button>
</div>
</div>
<div>
<p className="text-xs text-secondary-600 dark:text-secondary-400 mb-2">
Filter by host group:
</p>
<div className="flex items-center gap-2">
<input
type="text"
value={`curl -u "${new_token.token_key}:${new_token.token_secret}" "${server_url}/api/v1/api/hosts?hostgroup=Production"`}
readOnly
className="flex-1 px-3 py-2 text-xs 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"
/>
<button
type="button"
onClick={() =>
copy_to_clipboard(
`curl -u "${new_token.token_key}:${new_token.token_secret}" "${server_url}/api/v1/api/hosts?hostgroup=Production"`,
"api-curl-filter",
)
}
className="btn-primary p-2"
title="Copy cURL command"
>
{copy_success["api-curl-filter"] ? (
<CheckCircle className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</button>
</div>
</div>
</div>
<p className="text-xs text-secondary-500 dark:text-secondary-400 mt-3">
💡 Replace "Production" with your host group name or UUID
</p>
</div>
)}
{activeTab === "proxmox" && ( {activeTab === "proxmox" && (
<div className="mt-6"> <div className="mt-6">
<div className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2"> <div className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
@@ -1371,6 +1845,154 @@ const Integrations = () => {
</div> </div>
</div> </div>
)} )}
{/* Edit API Credential Modal */}
{show_edit_modal && edit_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 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">
Edit API Credential
</h2>
<button
type="button"
onClick={() => {
setShowEditModal(false);
setEditToken(null);
}}
className="text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-200"
>
<X className="h-6 w-6" />
</button>
</div>
<form onSubmit={update_token} className="space-y-4">
<div className="block">
<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Token Name
</span>
<input
type="text"
value={form_data.token_name}
readOnly
disabled
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-100 dark:bg-secondary-900 text-secondary-500 dark:text-secondary-400"
/>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Token name cannot be changed
</p>
</div>
{edit_token?.metadata?.integration_type === "api" && (
<div className="block">
<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
Scopes
</span>
<div className="border border-secondary-300 dark:border-secondary-600 rounded-md p-4 bg-secondary-50 dark:bg-secondary-900">
<div className="mb-3">
<p className="text-xs font-semibold text-secondary-700 dark:text-secondary-300 mb-2">
Host Permissions
</p>
<div className="space-y-2">
{["get", "put", "patch", "update", "delete"].map(
(action) => (
<label
key={action}
className="flex items-center gap-2"
>
<input
type="checkbox"
checked={
form_data.scopes?.host?.includes(action) ||
false
}
onChange={() =>
toggle_scope_action("host", action)
}
className="rounded border-secondary-300 dark:border-secondary-600 text-primary-600 focus:ring-primary-500 dark:focus:ring-primary-400"
/>
<span className="text-sm text-secondary-700 dark:text-secondary-300 uppercase">
{action}
</span>
<span className="text-xs text-secondary-500 dark:text-secondary-400">
{action === "get" && "- Read host data"}
{action === "put" && "- Replace host data"}
{action === "patch" && "- Update host data"}
{action === "update" && "- Modify host data"}
{action === "delete" && "- Delete hosts"}
</span>
</label>
),
)}
</div>
</div>
</div>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Update the permissions for this API credential
</p>
</div>
)}
<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"
>
Update Credential
</button>
<button
type="button"
onClick={() => {
setShowEditModal(false);
setEditToken(null);
}}
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>
)}
</SettingsLayout> </SettingsLayout>
); );
}; };