Merge pull request #88 from 9technologygroup/v1-2-7-agent

Agent modification
This commit is contained in:
9 Technology Group LTD
2025-09-29 20:55:55 +01:00
committed by GitHub
21 changed files with 1198 additions and 1239 deletions

View File

@@ -1,12 +1,12 @@
#!/bin/bash
# PatchMon Agent Script v1.2.6
# PatchMon Agent Script v1.2.7
# This script sends package update information to the PatchMon server using API credentials
# Configuration
PATCHMON_SERVER="${PATCHMON_SERVER:-http://localhost:3001}"
API_VERSION="v1"
AGENT_VERSION="1.2.6"
AGENT_VERSION="1.2.7"
CONFIG_FILE="/etc/patchmon/agent.conf"
CREDENTIALS_FILE="/etc/patchmon/credentials"
LOG_FILE="/var/log/patchmon-agent.log"
@@ -144,7 +144,7 @@ EOF
test_credentials() {
load_credentials
local response=$(curl -ksv -X POST \
local response=$(curl -ks -X POST \
-H "Content-Type: application/json" \
-H "X-API-ID: $API_ID" \
-H "X-API-KEY: $API_KEY" \
@@ -809,7 +809,7 @@ EOF
local payload=$(echo "$base_payload $merged_json" | jq -s '.[0] * .[1]')
local response=$(curl -ksv -X POST \
local response=$(curl -ks -X POST \
-H "Content-Type: application/json" \
-H "X-API-ID: $API_ID" \
-H "X-API-KEY: $API_KEY" \
@@ -821,25 +821,18 @@ EOF
success "Update sent successfully"
echo "$response" | grep -o '"packagesProcessed":[0-9]*' | cut -d':' -f2 | xargs -I {} info "Processed {} packages"
# Check for PatchMon agent update instructions (this updates the agent script, not system packages)
if echo "$response" | grep -q '"autoUpdate":{'; then
local auto_update_section=$(echo "$response" | grep -o '"autoUpdate":{[^}]*}')
local should_update=$(echo "$auto_update_section" | grep -o '"shouldUpdate":true' | cut -d':' -f2)
if [[ "$should_update" == "true" ]]; then
local latest_version=$(echo "$auto_update_section" | grep -o '"latestVersion":"[^"]*' | cut -d'"' -f4)
local current_version=$(echo "$auto_update_section" | grep -o '"currentVersion":"[^"]*' | cut -d'"' -f4)
local update_message=$(echo "$auto_update_section" | grep -o '"message":"[^"]*' | cut -d'"' -f4)
info "PatchMon agent update detected: $update_message"
info "Current version: $current_version, Latest version: $latest_version"
# Automatically run update-agent command to update the PatchMon agent script
info "Automatically updating PatchMon agent to latest version..."
# Check if auto-update is enabled and check for agent updates locally
if check_auto_update_enabled; then
info "Auto-update is enabled, checking for agent updates..."
if check_agent_update_needed; then
info "Agent update available, automatically updating..."
if "$0" update-agent; then
success "PatchMon agent update completed successfully"
else
warning "PatchMon agent update failed, but data was sent successfully"
fi
else
info "Agent is up to date"
fi
fi
@@ -877,7 +870,7 @@ EOF
ping_server() {
load_credentials
local response=$(curl -ksv -X POST \
local response=$(curl -ks -X POST \
-H "Content-Type: application/json" \
-H "X-API-ID: $API_ID" \
-H "X-API-KEY: $API_KEY" \
@@ -920,7 +913,7 @@ check_version() {
info "Checking for agent updates..."
local response=$(curl -ksv -X GET "$PATCHMON_SERVER/api/$API_VERSION/hosts/agent/version")
local response=$(curl -ks -H "X-API-ID: $API_ID" -H "X-API-KEY: $API_KEY" -X GET "$PATCHMON_SERVER/api/$API_VERSION/hosts/agent/version")
if [[ $? -eq 0 ]]; then
local current_version=$(echo "$response" | grep -o '"currentVersion":"[^"]*' | cut -d'"' -f4)
@@ -949,59 +942,147 @@ check_version() {
fi
}
# Check if auto-update is enabled (both globally and for this host)
check_auto_update_enabled() {
# Get settings from server using API credentials
local response=$(curl -ks -H "X-API-ID: $API_ID" -H "X-API-KEY: $API_KEY" -X GET "$PATCHMON_SERVER/api/$API_VERSION/hosts/settings" 2>/dev/null)
if [[ $? -ne 0 ]]; then
return 1
fi
# Check if both global and host auto-update are enabled
local global_auto_update=$(echo "$response" | grep -o '"auto_update":true' | cut -d':' -f2)
local host_auto_update=$(echo "$response" | grep -o '"host_auto_update":true' | cut -d':' -f2)
if [[ "$global_auto_update" == "true" && "$host_auto_update" == "true" ]]; then
return 0
else
return 1
fi
}
# Check if agent update is needed (internal function for auto-update)
check_agent_update_needed() {
# Get current agent timestamp
local current_timestamp=0
if [[ -f "$0" ]]; then
current_timestamp=$(stat -c %Y "$0" 2>/dev/null || stat -f %m "$0" 2>/dev/null || echo "0")
fi
# Get server agent info using API credentials
local response=$(curl -ks -H "X-API-ID: $API_ID" -H "X-API-KEY: $API_KEY" -X GET "$PATCHMON_SERVER/api/$API_VERSION/hosts/agent/timestamp" 2>/dev/null)
if [[ $? -eq 0 ]]; then
local server_version=$(echo "$response" | grep -o '"version":"[^"]*' | cut -d'"' -f4)
local server_timestamp=$(echo "$response" | grep -o '"timestamp":[0-9]*' | cut -d':' -f2)
local server_exists=$(echo "$response" | grep -o '"exists":true' | cut -d':' -f2)
if [[ "$server_exists" != "true" ]]; then
return 1
fi
# Check if update is needed
if [[ "$server_version" != "$AGENT_VERSION" ]]; then
return 0 # Update needed due to version mismatch
elif [[ "$server_timestamp" -gt "$current_timestamp" ]]; then
return 0 # Update needed due to newer timestamp
else
return 1 # No update needed
fi
else
return 1 # Failed to check
fi
}
# Check for agent updates based on version and timestamp (interactive command)
check_agent_update() {
load_credentials
info "Checking for agent updates..."
# Get current agent timestamp
local current_timestamp=0
if [[ -f "$0" ]]; then
current_timestamp=$(stat -c %Y "$0" 2>/dev/null || stat -f %m "$0" 2>/dev/null || echo "0")
fi
# Get server agent info using API credentials
local response=$(curl -ks -H "X-API-ID: $API_ID" -H "X-API-KEY: $API_KEY" -X GET "$PATCHMON_SERVER/api/$API_VERSION/hosts/agent/timestamp")
if [[ $? -eq 0 ]]; then
local server_version=$(echo "$response" | grep -o '"version":"[^"]*' | cut -d'"' -f4)
local server_timestamp=$(echo "$response" | grep -o '"timestamp":[0-9]*' | cut -d':' -f2)
local server_exists=$(echo "$response" | grep -o '"exists":true' | cut -d':' -f2)
if [[ "$server_exists" != "true" ]]; then
warning "No agent script found on server"
return 1
fi
info "Current agent version: $AGENT_VERSION (timestamp: $current_timestamp)"
info "Server agent version: $server_version (timestamp: $server_timestamp)"
# Check if update is needed
if [[ "$server_version" != "$AGENT_VERSION" ]]; then
info "Version mismatch detected - update needed"
return 0
elif [[ "$server_timestamp" -gt "$current_timestamp" ]]; then
info "Server script is newer - update needed"
return 0
else
info "Agent is up to date"
return 1
fi
else
error "Failed to check agent timestamp from server"
return 1
fi
}
# Update agent script
update_agent() {
load_credentials
info "Updating agent script..."
local response=$(curl -ksv -X GET "$PATCHMON_SERVER/api/$API_VERSION/hosts/agent/version")
local download_url="$PATCHMON_SERVER/api/$API_VERSION/hosts/agent/download"
if [[ $? -eq 0 ]]; then
local download_url=$(echo "$response" | grep -o '"downloadUrl":"[^"]*' | cut -d'"' -f4)
if [[ -z "$download_url" ]]; then
download_url="$PATCHMON_SERVER/api/$API_VERSION/hosts/agent/download"
elif [[ "$download_url" =~ ^/ ]]; then
# If download_url is relative, prepend the server URL
download_url="$PATCHMON_SERVER$download_url"
fi
info "Downloading latest agent from: $download_url"
# Create backup of current script
cp "$0" "$0.backup.$(date +%Y%m%d_%H%M%S)"
# Download new version
if curl -ksv -o "/tmp/patchmon-agent-new.sh" "$download_url"; then
# Verify the downloaded script is valid
if bash -n "/tmp/patchmon-agent-new.sh" 2>/dev/null; then
# Replace current script
mv "/tmp/patchmon-agent-new.sh" "$0"
chmod +x "$0"
success "Agent updated successfully"
info "Backup saved as: $0.backup.$(date +%Y%m%d_%H%M%S)"
# Get the new version number
local new_version=$(grep '^AGENT_VERSION=' "$0" | cut -d'"' -f2)
info "Updated to version: $new_version"
# Automatically run update to send new information to PatchMon
info "Sending updated information to PatchMon..."
if "$0" update; then
success "Successfully sent updated information to PatchMon"
else
warning "Failed to send updated information to PatchMon (this is not critical)"
fi
info "Downloading latest agent from: $download_url"
# Clean up old backups (keep only last 3)
ls -t "$0.backup."* 2>/dev/null | tail -n +4 | xargs -r rm -f
# Create backup of current script
local backup_file="$0.backup.$(date +%Y%m%d_%H%M%S)"
cp "$0" "$backup_file"
# Download new version using API credentials
if curl -ks -H "X-API-ID: $API_ID" -H "X-API-KEY: $API_KEY" -o "/tmp/patchmon-agent-new.sh" "$download_url"; then
# Verify the downloaded script is valid
if bash -n "/tmp/patchmon-agent-new.sh" 2>/dev/null; then
# Replace current script
mv "/tmp/patchmon-agent-new.sh" "$0"
chmod +x "$0"
success "Agent updated successfully"
info "Backup saved as: $backup_file"
# Get the new version number
local new_version=$(grep '^AGENT_VERSION=' "$0" | cut -d'"' -f2)
info "Updated to version: $new_version"
# Automatically run update to send new information to PatchMon
info "Sending updated information to PatchMon..."
if "$0" update; then
success "Successfully sent updated information to PatchMon"
else
error "Downloaded script is invalid"
rm -f "/tmp/patchmon-agent-new.sh"
warning "Failed to send updated information to PatchMon (this is not critical)"
fi
else
error "Failed to download new agent script"
error "Downloaded script is invalid"
rm -f "/tmp/patchmon-agent-new.sh"
fi
else
error "Failed to get update information"
error "Failed to download new agent script"
fi
}
@@ -1009,7 +1090,7 @@ update_agent() {
update_crontab() {
load_credentials
info "Updating crontab with current policy..."
local response=$(curl -ksv -X GET "$PATCHMON_SERVER/api/$API_VERSION/settings/update-interval")
local response=$(curl -ks -H "X-API-ID: $API_ID" -H "X-API-KEY: $API_KEY" -X GET "$PATCHMON_SERVER/api/$API_VERSION/settings/update-interval")
if [[ $? -eq 0 ]]; then
local update_interval=$(echo "$response" | grep -o '"updateInterval":[0-9]*' | cut -d':' -f2)
if [[ -n "$update_interval" ]]; then
@@ -1024,18 +1105,27 @@ update_crontab() {
expected_crontab="*/$update_interval * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1"
fi
# Get current crontab
local current_crontab=$(crontab -l 2>/dev/null | grep "patchmon-agent.sh update" | head -1)
# Get current crontab (without patchmon entries)
local current_crontab_without_patchmon=$(crontab -l 2>/dev/null | grep -v "/usr/local/bin/patchmon-agent.sh update" || true)
local current_patchmon_entry=$(crontab -l 2>/dev/null | grep "/usr/local/bin/patchmon-agent.sh update" | head -1)
# Check if crontab needs updating
if [[ "$current_crontab" == "$expected_crontab" ]]; then
if [[ "$current_patchmon_entry" == "$expected_crontab" ]]; then
info "Crontab is already up to date (interval: $update_interval minutes)"
return 0
fi
info "Setting update interval to $update_interval minutes"
echo "$expected_crontab" | crontab -
success "Crontab updated successfully"
# Combine existing cron (without patchmon entries) + new patchmon entry
{
if [[ -n "$current_crontab_without_patchmon" ]]; then
echo "$current_crontab_without_patchmon"
fi
echo "$expected_crontab"
} | crontab -
success "Crontab updated successfully (duplicates removed)"
else
error "Could not determine update interval from server"
fi
@@ -1147,7 +1237,7 @@ main() {
check_root
setup_directories
load_config
configure_credentials "$2" "$3"
configure_credentials "$2" "$3" "$4"
;;
"test")
check_root
@@ -1178,6 +1268,11 @@ main() {
load_config
check_version
;;
"check-agent-update")
setup_directories
load_config
check_agent_update
;;
"update-agent")
check_root
setup_directories
@@ -1195,22 +1290,23 @@ main() {
;;
*)
echo "PatchMon Agent v$AGENT_VERSION - API Credential Based"
echo "Usage: $0 {configure|test|update|ping|config|check-version|update-agent|update-crontab|diagnostics}"
echo "Usage: $0 {configure|test|update|ping|config|check-version|check-agent-update|update-agent|update-crontab|diagnostics}"
echo ""
echo "Commands:"
echo " configure <API_ID> <API_KEY> - Configure API credentials for this host"
echo " configure <API_ID> <API_KEY> [SERVER_URL] - Configure API credentials for this host"
echo " test - Test API credentials connectivity"
echo " update - Send package update information to server"
echo " ping - Test connectivity to server"
echo " config - Show current configuration"
echo " check-version - Check for agent updates"
echo " check-agent-update - Check for agent updates using timestamp comparison"
echo " update-agent - Update agent to latest version"
echo " update-crontab - Update crontab with current policy"
echo " diagnostics - Show detailed system diagnostics"
echo ""
echo "Setup Process:"
echo " 1. Contact your PatchMon administrator to create a host entry"
echo " 2. Run: $0 configure <API_ID> <API_KEY> (provided by admin)"
echo " 2. Run: $0 configure <API_ID> <API_KEY> [SERVER_URL] (provided by admin)"
echo " 3. Run: $0 test (to verify connection)"
echo " 4. Run: $0 update (to send initial package data)"
echo ""

View File

@@ -1,7 +1,7 @@
#!/bin/bash
# PatchMon Agent Installation Script
# Usage: curl -ksSL {PATCHMON_URL}/api/v1/hosts/install | bash -s -- {PATCHMON_URL} {API_ID} {API_KEY}
# Usage: curl -ks {PATCHMON_URL}/api/v1/hosts/install -H "X-API-ID: {API_ID}" -H "X-API-KEY: {API_KEY}" | bash
set -e
@@ -35,10 +35,34 @@ if [[ $EUID -ne 0 ]]; then
error "This script must be run as root (use sudo)"
fi
# Clean up old files (keep only last 3 of each type)
cleanup_old_files() {
# Clean up old credential backups
ls -t /etc/patchmon/credentials.backup.* 2>/dev/null | tail -n +4 | xargs -r rm -f
# Clean up old agent backups
ls -t /usr/local/bin/patchmon-agent.sh.backup.* 2>/dev/null | tail -n +4 | xargs -r rm -f
# Clean up old log files
ls -t /var/log/patchmon-agent.log.old.* 2>/dev/null | tail -n +4 | xargs -r rm -f
}
# Run cleanup at start
cleanup_old_files
# Parse arguments from environment (passed via HTTP headers)
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."
fi
info "🚀 Starting PatchMon Agent Installation..."
info "📋 Server: $PATCHMON_URL"
info "🔑 API ID: ${API_ID:0:16}..."
# Install required dependencies
info "📦 Installing required dependencies..."
# Detect package manager and install jq
# Detect package manager and install jq and curl
if command -v apt-get >/dev/null 2>&1; then
# Debian/Ubuntu
apt-get update >/dev/null 2>&1
@@ -62,76 +86,40 @@ else
warning "Could not detect package manager. Please ensure 'jq' and 'curl' are installed manually."
fi
# Verify jq installation
if ! command -v jq >/dev/null 2>&1; then
error "Failed to install 'jq'. Please install it manually: https://stedolan.github.io/jq/download/"
# Step 1: Handle existing configuration directory
info "📁 Setting up configuration directory..."
# Check if configuration directory already exists
if [[ -d "/etc/patchmon" ]]; then
warning "⚠️ Configuration directory already exists at /etc/patchmon"
warning "⚠️ Preserving existing configuration files"
# List existing files for user awareness
info "📋 Existing files in /etc/patchmon:"
ls -la /etc/patchmon/ 2>/dev/null | grep -v "^total" | while read -r line; do
echo " $line"
done
else
info "📁 Creating new configuration directory..."
mkdir -p /etc/patchmon
fi
success "Dependencies installed successfully!"
# Step 2: Create credentials file
info "🔐 Creating API credentials file..."
# Default server URL (will be replaced by backend with configured URL)
PATCHMON_URL="http://localhost:3001"
# Parse arguments
if [[ $# -ne 3 ]]; then
echo "Usage: curl -ksSL {PATCHMON_URL}/api/v1/hosts/install | bash -s -- {PATCHMON_URL} {API_ID} {API_KEY}"
echo ""
echo "Example:"
echo "curl -ksSL http://patchmon.example.com/api/v1/hosts/install | bash -s -- http://patchmon.example.com patchmon_1a2b3c4d abcd1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
echo ""
echo "Contact your PatchMon administrator to get your API credentials."
exit 1
# Check if credentials file already exists
if [[ -f "/etc/patchmon/credentials" ]]; then
warning "⚠️ Credentials file already exists at /etc/patchmon/credentials"
warning "⚠️ Moving existing file out of the way for fresh installation"
# Clean up old credential backups (keep only last 3)
ls -t /etc/patchmon/credentials.backup.* 2>/dev/null | tail -n +4 | xargs -r rm -f
# Move existing file out of the way
mv /etc/patchmon/credentials /etc/patchmon/credentials.backup.$(date +%Y%m%d_%H%M%S)
info "📋 Moved existing credentials to: /etc/patchmon/credentials.backup.$(date +%Y%m%d_%H%M%S)"
fi
PATCHMON_URL="$1"
API_ID="$2"
API_KEY="$3"
# Validate inputs
if [[ ! "$PATCHMON_URL" =~ ^https?:// ]]; then
error "Invalid URL format. Must start with http:// or https://"
fi
if [[ ! "$API_ID" =~ ^patchmon_[a-f0-9]{16}$ ]]; then
error "Invalid API ID format. API ID should be in format: patchmon_xxxxxxxxxxxxxxxx"
fi
if [[ ! "$API_KEY" =~ ^[a-f0-9]{64}$ ]]; then
error "Invalid API Key format. API Key should be 64 hexadecimal characters."
fi
info "🚀 Installing PatchMon Agent..."
info " Server: $PATCHMON_URL"
info " API ID: $API_ID"
# Create patchmon directory
info "📁 Creating configuration directory..."
mkdir -p /etc/patchmon
# Download the agent script
info "📥 Downloading PatchMon agent script..."
curl -ksSL "$PATCHMON_URL/api/v1/hosts/agent/download" -o /usr/local/bin/patchmon-agent.sh
chmod +x /usr/local/bin/patchmon-agent.sh
# Get the agent version from the downloaded script
AGENT_VERSION=$(grep '^AGENT_VERSION=' /usr/local/bin/patchmon-agent.sh | cut -d'"' -f2)
info "📋 Agent version: $AGENT_VERSION"
# Get expected agent version from server
EXPECTED_VERSION=$(curl -ksv "$PATCHMON_URL/api/v1/hosts/agent/version" | grep -o '"currentVersion":"[^"]*' | cut -d'"' -f4 2>/dev/null || echo "Unknown")
if [[ "$EXPECTED_VERSION" != "Unknown" ]]; then
info "📋 Expected version: $EXPECTED_VERSION"
if [[ "$AGENT_VERSION" != "$EXPECTED_VERSION" ]]; then
warning "⚠️ Agent version mismatch! Installed: $AGENT_VERSION, Expected: $EXPECTED_VERSION"
fi
fi
# Get update interval policy from server
UPDATE_INTERVAL=$(curl -ksv "$PATCHMON_URL/api/v1/settings/update-interval" | grep -o '"updateInterval":[0-9]*' | cut -d':' -f2 2>/dev/null || echo "60")
info "📋 Update interval: $UPDATE_INTERVAL minutes"
# Create credentials file
info "🔐 Setting up API credentials..."
cat > /etc/patchmon/credentials << EOF
# PatchMon API Credentials
# Generated on $(date)
@@ -139,63 +127,141 @@ PATCHMON_URL="$PATCHMON_URL"
API_ID="$API_ID"
API_KEY="$API_KEY"
EOF
chmod 600 /etc/patchmon/credentials
# Test the configuration
info "🧪 Testing configuration..."
# Step 3: Download the agent script using API credentials
info "📥 Downloading PatchMon agent script..."
# Check if agent script already exists
if [[ -f "/usr/local/bin/patchmon-agent.sh" ]]; then
warning "⚠️ Agent script already exists at /usr/local/bin/patchmon-agent.sh"
warning "⚠️ Moving existing file out of the way for fresh installation"
# Clean up old agent backups (keep only last 3)
ls -t /usr/local/bin/patchmon-agent.sh.backup.* 2>/dev/null | tail -n +4 | xargs -r rm -f
# Move existing file out of the way
mv /usr/local/bin/patchmon-agent.sh /usr/local/bin/patchmon-agent.sh.backup.$(date +%Y%m%d_%H%M%S)
info "📋 Moved existing agent to: /usr/local/bin/patchmon-agent.sh.backup.$(date +%Y%m%d_%H%M%S)"
fi
curl -ks \
-H "X-API-ID: $API_ID" \
-H "X-API-KEY: $API_KEY" \
"$PATCHMON_URL/api/v1/hosts/agent/download" \
-o /usr/local/bin/patchmon-agent.sh
chmod +x /usr/local/bin/patchmon-agent.sh
# Get the agent version from the downloaded script
AGENT_VERSION=$(grep '^AGENT_VERSION=' /usr/local/bin/patchmon-agent.sh | cut -d'"' -f2 2>/dev/null || echo "Unknown")
info "📋 Agent version: $AGENT_VERSION"
# Handle existing log files
if [[ -f "/var/log/patchmon-agent.log" ]]; then
warning "⚠️ Existing log file found at /var/log/patchmon-agent.log"
warning "⚠️ Rotating log file for fresh start"
# Rotate the log file
mv /var/log/patchmon-agent.log /var/log/patchmon-agent.log.old.$(date +%Y%m%d_%H%M%S)
info "📋 Log file rotated to: /var/log/patchmon-agent.log.old.$(date +%Y%m%d_%H%M%S)"
fi
# Step 4: Test the configuration
info "🧪 Testing API credentials and connectivity..."
if /usr/local/bin/patchmon-agent.sh test; then
success "Configuration test passed!"
success "✅ API credentials are valid and server is reachable"
else
error "Configuration test failed. Please check your credentials."
error "❌ Failed to validate API credentials or reach server"
fi
# Send initial update
info "📊 Sending initial package data..."
# Step 5: Send initial data
info "📊 Sending initial package data to server..."
if /usr/local/bin/patchmon-agent.sh update; then
success "Initial package data sent successfully!"
success "Initial package data sent successfully"
else
warning "Initial package data failed, but agent is configured. You can run 'patchmon-agent.sh update' manually."
warning "⚠️ Failed to send initial data. You can retry later with: /usr/local/bin/patchmon-agent.sh update"
fi
# Setup crontab for automatic package status updates
info "Setting up automatic package status update every $UPDATE_INTERVAL minutes..."
# Step 6: Get update interval policy from server and setup crontab
info "⏰ Getting update interval policy from server..."
UPDATE_INTERVAL=$(curl -ks \
-H "X-API-ID: $API_ID" \
-H "X-API-KEY: $API_KEY" \
"$PATCHMON_URL/api/v1/settings/update-interval" | \
grep -o '"updateInterval":[0-9]*' | cut -d':' -f2 2>/dev/null || echo "60")
# Check if patchmon cron job already exists
PATCHMON_CRON_EXISTS=$(crontab -l 2>/dev/null | grep -c "patchmon-agent.sh update" || true)
info "📋 Update interval: $UPDATE_INTERVAL minutes"
if [[ $PATCHMON_CRON_EXISTS -gt 0 ]]; then
info " Existing PatchMon cron job found, removing old entry..."
# Remove existing patchmon cron entries and preserve other entries
(crontab -l 2>/dev/null | grep -v "patchmon-agent.sh update") | crontab -
# Setup crontab (smart duplicate detection)
info "📅 Setting up automated updates..."
# Check if PatchMon cron entries already exist
if crontab -l 2>/dev/null | grep -q "/usr/local/bin/patchmon-agent.sh update"; then
warning "⚠️ Existing PatchMon cron entries found"
warning "⚠️ These will be replaced with new schedule"
fi
if [[ $UPDATE_INTERVAL -eq 60 ]]; then
# Hourly updates - safely append to existing crontab
(crontab -l 2>/dev/null; echo "0 * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1") | crontab -
else
# Custom interval updates - safely append to existing crontab
(crontab -l 2>/dev/null; echo "*/$UPDATE_INTERVAL * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1") | crontab -
fi
# Function to setup crontab without duplicates
setup_crontab() {
local update_interval="$1"
local patchmon_pattern="/usr/local/bin/patchmon-agent.sh update"
# Get current crontab, remove any existing patchmon entries
local current_cron=$(crontab -l 2>/dev/null | grep -v "$patchmon_pattern" || true)
# Determine new cron entry
local new_entry
if [[ "$update_interval" -eq 60 ]]; then
# Hourly updates - use a random minute to spread load
local current_minute=$(date +%M)
new_entry="$current_minute * * * * $patchmon_pattern >/dev/null 2>&1"
info "📋 Configuring hourly updates at minute $current_minute"
else
# Custom interval updates
new_entry="*/$update_interval * * * * $patchmon_pattern >/dev/null 2>&1"
info "📋 Configuring updates every $update_interval minutes"
fi
# Combine existing cron (without patchmon entries) + new entry
{
if [[ -n "$current_cron" ]]; then
echo "$current_cron"
fi
echo "$new_entry"
} | crontab -
success "✅ Crontab configured successfully (duplicates removed)"
}
success "🎉 PatchMon Agent installation complete!"
setup_crontab "$UPDATE_INTERVAL"
# Installation complete
success "🎉 PatchMon Agent installation completed successfully!"
echo ""
echo "📋 Installation Summary:"
echo " • Dependencies installed: jq, curl"
echo -e "${GREEN}📋 Installation Summary:${NC}"
echo " • Configuration directory: /etc/patchmon"
echo " • Agent installed: /usr/local/bin/patchmon-agent.sh"
echo "Agent version: $AGENT_VERSION"
if [[ "$EXPECTED_VERSION" != "Unknown" ]]; then
echo "Expected version: $EXPECTED_VERSION"
fi
echo " • Config directory: /etc/patchmon/"
echo " • Credentials file: /etc/patchmon/credentials"
echo " • Status updates: Every $UPDATE_INTERVAL minutes via crontab"
echo " • View logs: tail -f /var/log/patchmon-agent.log"
echo ""
echo "🔧 Manual commands:"
echo " • Test connection: patchmon-agent.sh test"
echo " • Send update: patchmon-agent.sh update"
echo " • Check status: patchmon-agent.sh ping"
echo ""
success "Your host is now connected to PatchMon!"
echo " • Dependencies installed: jq, curl"
echo " • Crontab configured for automatic updates"
echo " • API credentials configured and tested"
# Check for moved files and show them
MOVED_FILES=$(ls /etc/patchmon/credentials.backup.* /usr/local/bin/patchmon-agent.sh.backup.* /var/log/patchmon-agent.log.old.* 2>/dev/null || true)
if [[ -n "$MOVED_FILES" ]]; then
echo ""
echo -e "${YELLOW}📋 Files Moved for Fresh Installation:${NC}"
echo "$MOVED_FILES" | while read -r moved_file; do
echo "$moved_file"
done
echo ""
echo -e "${BLUE}💡 Note: Old files are automatically cleaned up (keeping last 3)${NC}"
fi
echo ""
echo -e "${BLUE}🔧 Management Commands:${NC}"
echo " • Test connection: /usr/local/bin/patchmon-agent.sh test"
echo " • Manual update: /usr/local/bin/patchmon-agent.sh update"
echo " • Check status: /usr/local/bin/patchmon-agent.sh diagnostics"
echo ""
success "✅ Your system is now being monitored by PatchMon!"

217
agents/patchmon_remove.sh Executable file
View File

@@ -0,0 +1,217 @@
#!/bin/bash
# PatchMon Agent Removal Script
# Usage: curl -ks {PATCHMON_URL}/api/v1/hosts/remove | bash
# This script completely removes PatchMon from the system
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Functions
error() {
echo -e "${RED}❌ ERROR: $1${NC}" >&2
exit 1
}
info() {
echo -e "${BLUE} $1${NC}"
}
success() {
echo -e "${GREEN}$1${NC}"
}
warning() {
echo -e "${YELLOW}⚠️ $1${NC}"
}
# Check if running as root
if [[ $EUID -ne 0 ]]; then
error "This script must be run as root (use sudo)"
fi
info "🗑️ Starting PatchMon Agent Removal..."
echo ""
# Step 1: Stop any running PatchMon processes
info "🛑 Stopping PatchMon processes..."
if pgrep -f "patchmon-agent.sh" >/dev/null; then
warning "Found running PatchMon processes, stopping them..."
pkill -f "patchmon-agent.sh" || true
sleep 2
success "PatchMon processes stopped"
else
info "No running PatchMon processes found"
fi
# Step 2: Remove crontab entries
info "📅 Removing PatchMon crontab entries..."
if crontab -l 2>/dev/null | grep -q "patchmon-agent.sh"; then
warning "Found PatchMon crontab entries, removing them..."
crontab -l 2>/dev/null | grep -v "patchmon-agent.sh" | crontab -
success "Crontab entries removed"
else
info "No PatchMon crontab entries found"
fi
# Step 3: Remove agent script
info "📄 Removing agent script..."
if [[ -f "/usr/local/bin/patchmon-agent.sh" ]]; then
warning "Removing agent script: /usr/local/bin/patchmon-agent.sh"
rm -f /usr/local/bin/patchmon-agent.sh
success "Agent script removed"
else
info "Agent script not found"
fi
# Step 4: Remove configuration directory and files
info "📁 Removing configuration files..."
if [[ -d "/etc/patchmon" ]]; then
warning "Removing configuration directory: /etc/patchmon"
# Show what's being removed
info "📋 Files in /etc/patchmon:"
ls -la /etc/patchmon/ 2>/dev/null | grep -v "^total" | while read -r line; do
echo " $line"
done
# Remove the directory
rm -rf /etc/patchmon
success "Configuration directory removed"
else
info "Configuration directory not found"
fi
# Step 5: Remove log files
info "📝 Removing log files..."
if [[ -f "/var/log/patchmon-agent.log" ]]; then
warning "Removing log file: /var/log/patchmon-agent.log"
rm -f /var/log/patchmon-agent.log
success "Log file removed"
else
info "Log file not found"
fi
# Step 6: Clean up backup files (optional)
info "🧹 Cleaning up backup files..."
BACKUP_COUNT=0
# Count credential backups
CRED_BACKUPS=$(ls /etc/patchmon/credentials.backup.* 2>/dev/null | wc -l || echo "0")
if [[ $CRED_BACKUPS -gt 0 ]]; then
BACKUP_COUNT=$((BACKUP_COUNT + CRED_BACKUPS))
fi
# Count agent backups
AGENT_BACKUPS=$(ls /usr/local/bin/patchmon-agent.sh.backup.* 2>/dev/null | wc -l || echo "0")
if [[ $AGENT_BACKUPS -gt 0 ]]; then
BACKUP_COUNT=$((BACKUP_COUNT + AGENT_BACKUPS))
fi
# Count log backups
LOG_BACKUPS=$(ls /var/log/patchmon-agent.log.old.* 2>/dev/null | wc -l || echo "0")
if [[ $LOG_BACKUPS -gt 0 ]]; then
BACKUP_COUNT=$((BACKUP_COUNT + LOG_BACKUPS))
fi
if [[ $BACKUP_COUNT -gt 0 ]]; then
warning "Found $BACKUP_COUNT backup files"
echo ""
echo -e "${YELLOW}📋 Backup files found:${NC}"
# Show credential backups
if [[ $CRED_BACKUPS -gt 0 ]]; then
echo " Credential backups:"
ls /etc/patchmon/credentials.backup.* 2>/dev/null | while read -r file; do
echo "$file"
done
fi
# Show agent backups
if [[ $AGENT_BACKUPS -gt 0 ]]; then
echo " Agent script backups:"
ls /usr/local/bin/patchmon-agent.sh.backup.* 2>/dev/null | while read -r file; do
echo "$file"
done
fi
# Show log backups
if [[ $LOG_BACKUPS -gt 0 ]]; then
echo " Log file backups:"
ls /var/log/patchmon-agent.log.old.* 2>/dev/null | while read -r file; do
echo "$file"
done
fi
echo ""
echo -e "${BLUE}💡 Note: Backup files are preserved for safety${NC}"
echo -e "${BLUE}💡 You can remove them manually if not needed${NC}"
else
info "No backup files found"
fi
# Step 7: Remove dependencies (optional)
info "📦 Checking for PatchMon-specific dependencies..."
if command -v jq >/dev/null 2>&1; then
warning "jq is installed (used by PatchMon)"
echo -e "${BLUE}💡 Note: jq may be used by other applications${NC}"
echo -e "${BLUE}💡 Consider keeping it unless you're sure it's not needed${NC}"
else
info "jq not found"
fi
if command -v curl >/dev/null 2>&1; then
warning "curl is installed (used by PatchMon)"
echo -e "${BLUE}💡 Note: curl is commonly used by many applications${NC}"
echo -e "${BLUE}💡 Consider keeping it unless you're sure it's not needed${NC}"
else
info "curl not found"
fi
# Step 8: Final verification
info "🔍 Verifying removal..."
REMAINING_FILES=0
if [[ -f "/usr/local/bin/patchmon-agent.sh" ]]; then
REMAINING_FILES=$((REMAINING_FILES + 1))
fi
if [[ -d "/etc/patchmon" ]]; then
REMAINING_FILES=$((REMAINING_FILES + 1))
fi
if [[ -f "/var/log/patchmon-agent.log" ]]; then
REMAINING_FILES=$((REMAINING_FILES + 1))
fi
if crontab -l 2>/dev/null | grep -q "patchmon-agent.sh"; then
REMAINING_FILES=$((REMAINING_FILES + 1))
fi
if [[ $REMAINING_FILES -eq 0 ]]; then
success "✅ PatchMon has been completely removed from the system!"
else
warning "⚠️ Some PatchMon files may still remain ($REMAINING_FILES items)"
echo -e "${BLUE}💡 You may need to remove them manually${NC}"
fi
echo ""
echo -e "${GREEN}📋 Removal Summary:${NC}"
echo " • Agent script: Removed"
echo " • Configuration files: Removed"
echo " • Log files: Removed"
echo " • Crontab entries: Removed"
echo " • Running processes: Stopped"
echo " • Backup files: Preserved (if any)"
echo ""
echo -e "${BLUE}🔧 Manual cleanup (if needed):${NC}"
echo " • Remove backup files: rm /etc/patchmon/credentials.backup.* /usr/local/bin/patchmon-agent.sh.backup.* /var/log/patchmon-agent.log.old.*"
echo " • Remove dependencies: apt remove jq curl (if not needed by other apps)"
echo ""
success "🎉 PatchMon removal completed!"

View File

@@ -1,6 +1,6 @@
{
"name": "patchmon-backend",
"version": "1.2.6",
"version": "1.2.7",
"description": "Backend API for Linux Patch Monitoring System",
"license": "AGPL-3.0",
"main": "src/server.js",

View File

@@ -0,0 +1,2 @@
-- DropTable
DROP TABLE "agent_versions";

View File

@@ -7,18 +7,6 @@ datasource db {
url = env("DATABASE_URL")
}
model agent_versions {
id String @id
version String @unique
is_current Boolean @default(false)
release_notes String?
download_url String?
min_server_version String?
created_at DateTime @default(now())
updated_at DateTime
is_default Boolean @default(false)
script_content String?
}
model dashboard_preferences {
id String @id

View File

@@ -37,7 +37,7 @@ function createPrismaClient() {
},
},
log:
process.env.NODE_ENV === "development"
process.env.PRISMA_LOG_QUERIES === "true"
? ["query", "info", "warn", "error"]
: ["warn", "error"],
errorFormat: "pretty",
@@ -66,17 +66,21 @@ async function waitForDatabase(prisma, options = {}) {
parseInt(process.env.PM_DB_CONN_WAIT_INTERVAL, 10) ||
2;
console.log(
`Waiting for database connection (max ${maxAttempts} attempts, ${waitInterval}s interval)...`,
);
if (process.env.ENABLE_LOGGING === "true") {
console.log(
`Waiting for database connection (max ${maxAttempts} attempts, ${waitInterval}s interval)...`,
);
}
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const isConnected = await checkDatabaseConnection(prisma);
if (isConnected) {
console.log(
`Database connected successfully after ${attempt} attempt(s)`,
);
if (process.env.ENABLE_LOGGING === "true") {
console.log(
`Database connected successfully after ${attempt} attempt(s)`,
);
}
return true;
}
} catch {
@@ -84,9 +88,11 @@ async function waitForDatabase(prisma, options = {}) {
}
if (attempt < maxAttempts) {
console.log(
`⏳ Database not ready (attempt ${attempt}/${maxAttempts}), retrying in ${waitInterval}s...`,
);
if (process.env.ENABLE_LOGGING === "true") {
console.log(
`⏳ Database not ready (attempt ${attempt}/${maxAttempts}), retrying in ${waitInterval}s...`,
);
}
await new Promise((resolve) => setTimeout(resolve, waitInterval * 1000));
}
}

View File

@@ -3,8 +3,8 @@ const { PrismaClient } = require("@prisma/client");
const { body, validationResult } = require("express-validator");
const { v4: uuidv4 } = require("uuid");
const crypto = require("node:crypto");
const path = require("node:path");
const fs = require("node:fs");
const _path = require("node:path");
const _fs = require("node:fs");
const { authenticateToken, _requireAdmin } = require("../middleware/auth");
const {
requireManageHosts,
@@ -14,72 +14,48 @@ const {
const router = express.Router();
const prisma = new PrismaClient();
// Public endpoint to download the agent script
// Secure endpoint to download the agent script (requires API authentication)
router.get("/agent/download", async (req, res) => {
try {
const { version } = req.query;
// Verify API credentials
const apiId = req.headers["x-api-id"];
const apiKey = req.headers["x-api-key"];
let agentVersion;
if (version) {
// Download specific version
agentVersion = await prisma.agent_versions.findUnique({
where: { version },
});
if (!agentVersion) {
return res.status(404).json({ error: "Agent version not found" });
}
} else {
// Download current version (latest)
agentVersion = await prisma.agent_versions.findFirst({
where: { is_current: true },
orderBy: { created_at: "desc" },
});
if (!agentVersion) {
// Fallback to default version
agentVersion = await prisma.agent_versions.findFirst({
where: { is_default: true },
orderBy: { created_at: "desc" },
});
}
if (!apiId || !apiKey) {
return res.status(401).json({ error: "API credentials required" });
}
// Use script content from database if available, otherwise fallback to file
if (agentVersion?.script_content) {
// Convert Windows line endings to Unix line endings
const scriptContent = agentVersion.script_content
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
res.setHeader("Content-Type", "application/x-shellscript");
res.setHeader(
"Content-Disposition",
`attachment; filename="patchmon-agent-${agentVersion.version}.sh"`,
);
res.send(scriptContent);
} else {
// Fallback to file system when no database version exists or script has no content
const agentPath = path.join(
__dirname,
"../../../agents/patchmon-agent.sh",
);
if (!fs.existsSync(agentPath)) {
return res.status(404).json({ error: "Agent script not found" });
}
// Read file and convert line endings
const scriptContent = fs
.readFileSync(agentPath, "utf8")
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
res.setHeader("Content-Type", "application/x-shellscript");
const version = agentVersion ? `-${agentVersion.version}` : "";
res.setHeader(
"Content-Disposition",
`attachment; filename="patchmon-agent${version}.sh"`,
);
res.send(scriptContent);
// Validate API credentials
const host = await prisma.hosts.findUnique({
where: { api_id: apiId },
});
if (!host || host.api_key !== apiKey) {
return res.status(401).json({ error: "Invalid API credentials" });
}
// Serve agent script directly from file system
const fs = require("node:fs");
const path = require("node:path");
const agentPath = path.join(__dirname, "../../../agents/patchmon-agent.sh");
if (!fs.existsSync(agentPath)) {
return res.status(404).json({ error: "Agent script not found" });
}
// Read file and convert line endings
const scriptContent = fs
.readFileSync(agentPath, "utf8")
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
res.setHeader("Content-Type", "application/x-shellscript");
res.setHeader(
"Content-Disposition",
'attachment; filename="patchmon-agent.sh"',
);
res.send(scriptContent);
} catch (error) {
console.error("Agent download error:", error);
res.status(500).json({ error: "Failed to download agent script" });
@@ -89,21 +65,32 @@ router.get("/agent/download", async (req, res) => {
// Version check endpoint for agents
router.get("/agent/version", async (_req, res) => {
try {
const currentVersion = await prisma.agent_versions.findFirst({
where: { is_current: true },
orderBy: { created_at: "desc" },
});
const fs = require("node:fs");
const path = require("node:path");
if (!currentVersion) {
return res.status(404).json({ error: "No current agent version found" });
// Read version directly from agent script file
const agentPath = path.join(__dirname, "../../../agents/patchmon-agent.sh");
if (!fs.existsSync(agentPath)) {
return res.status(404).json({ error: "Agent script not found" });
}
const scriptContent = fs.readFileSync(agentPath, "utf8");
const versionMatch = scriptContent.match(/AGENT_VERSION="([^"]+)"/);
if (!versionMatch) {
return res
.status(500)
.json({ error: "Could not extract version from agent script" });
}
const currentVersion = versionMatch[1];
res.json({
currentVersion: currentVersion.version,
downloadUrl:
currentVersion.download_url || `/api/v1/hosts/agent/download`,
releaseNotes: currentVersion.release_notes,
minServerVersion: currentVersion.min_server_version,
currentVersion: currentVersion,
downloadUrl: `/api/v1/hosts/agent/download`,
releaseNotes: `PatchMon Agent v${currentVersion}`,
minServerVersion: null,
});
} catch (error) {
console.error("Version check error:", error);
@@ -527,42 +514,7 @@ router.post(
});
});
// Check if agent auto-update is enabled and if there's a newer version available
let autoUpdateResponse = null;
try {
const settings = await prisma.settings.findFirst();
// Check both global agent auto-update setting AND host-specific agent auto-update setting
if (settings?.auto_update && host.auto_update) {
// Get current agent version from the request
const currentAgentVersion = req.body.agentVersion;
if (currentAgentVersion) {
// Get the latest agent version
const latestAgentVersion = await prisma.agent_versions.findFirst({
where: { is_current: true },
orderBy: { created_at: "desc" },
});
if (
latestAgentVersion &&
latestAgentVersion.version !== currentAgentVersion
) {
// There's a newer version available
autoUpdateResponse = {
shouldUpdate: true,
currentVersion: currentAgentVersion,
latestVersion: latestAgentVersion.version,
message:
"A newer agent version is available. Run: /usr/local/bin/patchmon-agent.sh update-agent",
updateCommand: "update-agent",
};
}
}
}
} catch (error) {
console.error("Agent auto-update check error:", error);
// Don't fail the update if agent auto-update check fails
}
// Agent auto-update is now handled client-side by the agent itself
const response = {
message: "Host updated successfully",
@@ -571,11 +523,6 @@ router.post(
securityUpdates: securityCount,
};
// Add agent auto-update response if available
if (autoUpdateResponse) {
response.autoUpdate = autoUpdateResponse;
}
// Check if crontab update is needed (when update interval changes)
// This is a simple check - if the host has auto-update enabled, we'll suggest crontab update
if (host.auto_update) {
@@ -1103,9 +1050,26 @@ router.patch(
},
);
// Serve the installation script
router.get("/install", async (_req, res) => {
// Serve the installation script (requires API authentication)
router.get("/install", async (req, res) => {
try {
// Verify API credentials
const apiId = req.headers["x-api-id"];
const apiKey = req.headers["x-api-key"];
if (!apiId || !apiKey) {
return res.status(401).json({ error: "API credentials required" });
}
// Validate API credentials
const host = await prisma.hosts.findUnique({
where: { api_id: apiId },
});
if (!host || host.api_key !== apiKey) {
return res.status(401).json({ error: "Invalid API credentials" });
}
const fs = require("node:fs");
const path = require("node:path");
@@ -1124,14 +1088,11 @@ router.get("/install", async (_req, res) => {
script = script.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
// Get the configured server URL from settings
let serverUrl = "http://localhost:3001";
try {
const settings = await prisma.settings.findFirst();
if (settings) {
// Replace the default server URL in the script with the configured one
script = script.replace(
/PATCHMON_URL="[^"]*"/g,
`PATCHMON_URL="${settings.server_url}"`,
);
if (settings?.server_url) {
serverUrl = settings.server_url;
}
} catch (settingsError) {
console.warn(
@@ -1140,6 +1101,18 @@ router.get("/install", async (_req, res) => {
);
}
// Inject the API credentials and server URL into the script as environment variables
const envVars = `#!/bin/bash
export PATCHMON_URL="${serverUrl}"
export API_ID="${host.api_id}"
export API_KEY="${host.api_key}"
`;
// Remove the shebang from the original script and prepend our env vars
script = script.replace(/^#!/, "#");
script = envVars + script;
res.setHeader("Content-Type", "text/plain");
res.setHeader(
"Content-Disposition",
@@ -1152,215 +1125,247 @@ router.get("/install", async (_req, res) => {
}
});
// ==================== AGENT VERSION MANAGEMENT ====================
// Serve the removal script (public endpoint - no authentication required)
router.get("/remove", async (_req, res) => {
try {
const fs = require("node:fs");
const path = require("node:path");
// Get all agent versions (admin only)
const scriptPath = path.join(
__dirname,
"../../../agents/patchmon_remove.sh",
);
if (!fs.existsSync(scriptPath)) {
return res.status(404).json({ error: "Removal script not found" });
}
// Read the script content
const script = fs.readFileSync(scriptPath, "utf8");
// Set appropriate headers for script download
res.setHeader("Content-Type", "text/plain");
res.setHeader(
"Content-Disposition",
'inline; filename="patchmon_remove.sh"',
);
res.send(script);
} catch (error) {
console.error("Removal script error:", error);
res.status(500).json({ error: "Failed to serve removal script" });
}
});
// ==================== AGENT FILE MANAGEMENT ====================
// Get agent file information (admin only)
router.get(
"/agent/versions",
"/agent/info",
authenticateToken,
requireManageSettings,
async (_req, res) => {
try {
const versions = await prisma.agent_versions.findMany({
orderBy: { created_at: "desc" },
});
const fs = require("node:fs").promises;
const path = require("node:path");
res.json(versions);
} catch (error) {
console.error("Get agent versions error:", error);
res.status(500).json({ error: "Failed to get agent versions" });
}
},
);
const agentPath = path.join(
__dirname,
"../../../agents/patchmon-agent.sh",
);
// Create new agent version (admin only)
router.post(
"/agent/versions",
authenticateToken,
requireManageSettings,
[
body("version").isLength({ min: 1 }).withMessage("Version is required"),
body("releaseNotes").optional().isString(),
body("downloadUrl")
.optional()
.isURL()
.withMessage("Download URL must be valid"),
body("minServerVersion").optional().isString(),
body("scriptContent").optional().isString(),
body("isDefault").optional().isBoolean(),
],
async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
try {
const stats = await fs.stat(agentPath);
const content = await fs.readFile(agentPath, "utf8");
const {
version,
releaseNotes,
downloadUrl,
minServerVersion,
scriptContent,
isDefault,
} = req.body;
// Extract version from agent script (look for AGENT_VERSION= line)
const versionMatch = content.match(/^AGENT_VERSION="([^"]+)"/m);
const version = versionMatch ? versionMatch[1] : "unknown";
// Check if version already exists
const existingVersion = await prisma.agent_versions.findUnique({
where: { version },
});
if (existingVersion) {
return res.status(400).json({ error: "Version already exists" });
}
// If this is being set as default, unset other defaults
if (isDefault) {
await prisma.agent_versions.updateMany({
where: { is_default: true },
data: {
is_default: false,
updated_at: new Date(),
},
});
}
const agentVersion = await prisma.agent_versions.create({
data: {
id: uuidv4(),
res.json({
exists: true,
version,
release_notes: releaseNotes,
download_url: downloadUrl,
min_server_version: minServerVersion,
script_content: scriptContent,
is_default: isDefault || false,
is_current: false,
updated_at: new Date(),
},
});
res.status(201).json(agentVersion);
lastModified: stats.mtime,
size: stats.size,
sizeFormatted: `${(stats.size / 1024).toFixed(1)} KB`,
});
} catch (error) {
if (error.code === "ENOENT") {
res.json({
exists: false,
version: null,
lastModified: null,
size: 0,
sizeFormatted: "0 KB",
});
} else {
throw error;
}
}
} catch (error) {
console.error("Create agent version error:", error);
res.status(500).json({ error: "Failed to create agent version" });
console.error("Get agent info error:", error);
res.status(500).json({ error: "Failed to get agent information" });
}
},
);
// Set current agent version (admin only)
router.patch(
"/agent/versions/:versionId/current",
// Update agent file (admin only)
router.post(
"/agent/upload",
authenticateToken,
requireManageSettings,
async (req, res) => {
try {
const { versionId } = req.params;
const { scriptContent } = req.body;
// First, unset all current versions
await prisma.agent_versions.updateMany({
where: { is_current: true },
data: { is_current: false, updated_at: new Date() },
});
if (!scriptContent || typeof scriptContent !== "string") {
return res.status(400).json({ error: "Script content is required" });
}
// Set the specified version as current
const agentVersion = await prisma.agent_versions.update({
where: { id: versionId },
data: { is_current: true, updated_at: new Date() },
});
res.json(agentVersion);
} catch (error) {
console.error("Set current agent version error:", error);
res.status(500).json({ error: "Failed to set current agent version" });
}
},
);
// Set default agent version (admin only)
router.patch(
"/agent/versions/:versionId/default",
authenticateToken,
requireManageSettings,
async (req, res) => {
try {
const { versionId } = req.params;
// First, unset all default versions
await prisma.agent_versions.updateMany({
where: { is_default: true },
data: { is_default: false, updated_at: new Date() },
});
// Set the specified version as default
const agentVersion = await prisma.agent_versions.update({
where: { id: versionId },
data: { is_default: true, updated_at: new Date() },
});
res.json(agentVersion);
} catch (error) {
console.error("Set default agent version error:", error);
res.status(500).json({ error: "Failed to set default agent version" });
}
},
);
// Delete agent version (admin only)
router.delete(
"/agent/versions/:versionId",
authenticateToken,
requireManageSettings,
async (req, res) => {
try {
const { versionId } = req.params;
// Validate versionId format
if (!versionId || versionId.length < 10) {
// Basic validation - check if it looks like a shell script
if (!scriptContent.trim().startsWith("#!/")) {
return res.status(400).json({
error: "Invalid agent version ID format",
details: "The provided ID does not match expected format",
error: "Invalid script format - must start with shebang (#!/...)",
});
}
const agentVersion = await prisma.agent_versions.findUnique({
where: { id: versionId },
});
const fs = require("node:fs").promises;
const path = require("node:path");
if (!agentVersion) {
return res.status(404).json({
error: "Agent version not found",
details: `No agent version found with ID: ${versionId}`,
suggestion:
"Please refresh the page to get the latest agent versions",
});
const agentPath = path.join(
__dirname,
"../../../agents/patchmon-agent.sh",
);
// Create backup of existing file
try {
const backupPath = `${agentPath}.backup.${Date.now()}`;
await fs.copyFile(agentPath, backupPath);
console.log(`Created backup: ${backupPath}`);
} catch (error) {
// Ignore if original doesn't exist
if (error.code !== "ENOENT") {
console.warn("Failed to create backup:", error.message);
}
}
if (agentVersion.is_current) {
return res.status(400).json({
error: "Cannot delete current agent version",
details: `Version ${agentVersion.version} is currently active`,
suggestion: "Set another version as current before deleting this one",
});
}
// Write new agent script
await fs.writeFile(agentPath, scriptContent, { mode: 0o755 });
await prisma.agent_versions.delete({
where: { id: versionId },
});
// Get updated file info
const stats = await fs.stat(agentPath);
const versionMatch = scriptContent.match(/^AGENT_VERSION="([^"]+)"/m);
const version = versionMatch ? versionMatch[1] : "unknown";
res.json({
message: "Agent version deleted successfully",
deletedVersion: agentVersion.version,
message: "Agent script updated successfully",
version,
lastModified: stats.mtime,
size: stats.size,
sizeFormatted: `${(stats.size / 1024).toFixed(1)} KB`,
});
} catch (error) {
console.error("Delete agent version error:", error);
res.status(500).json({
error: "Failed to delete agent version",
details: error.message,
});
console.error("Upload agent error:", error);
res.status(500).json({ error: "Failed to update agent script" });
}
},
);
// Get agent file timestamp for update checking (requires API credentials)
router.get("/agent/timestamp", async (req, res) => {
try {
// Check for API credentials
const apiId = req.headers["x-api-id"];
const apiKey = req.headers["x-api-key"];
if (!apiId || !apiKey) {
return res.status(401).json({ error: "API credentials required" });
}
// Verify API credentials
const host = await prisma.hosts.findFirst({
where: {
api_id: apiId,
api_key: apiKey,
},
});
if (!host) {
return res.status(401).json({ error: "Invalid API credentials" });
}
const fs = require("node:fs").promises;
const path = require("node:path");
const agentPath = path.join(__dirname, "../../../agents/patchmon-agent.sh");
try {
const stats = await fs.stat(agentPath);
const content = await fs.readFile(agentPath, "utf8");
// Extract version from agent script
const versionMatch = content.match(/^AGENT_VERSION="([^"]+)"/m);
const version = versionMatch ? versionMatch[1] : "unknown";
res.json({
version,
lastModified: stats.mtime,
timestamp: Math.floor(stats.mtime.getTime() / 1000), // Unix timestamp
exists: true,
});
} catch (error) {
if (error.code === "ENOENT") {
res.json({
version: null,
lastModified: null,
timestamp: 0,
exists: false,
});
} else {
throw error;
}
}
} catch (error) {
console.error("Get agent timestamp error:", error);
res.status(500).json({ error: "Failed to get agent timestamp" });
}
});
// Get settings for agent (requires API credentials)
router.get("/settings", async (req, res) => {
try {
// Check for API credentials
const apiId = req.headers["x-api-id"];
const apiKey = req.headers["x-api-key"];
if (!apiId || !apiKey) {
return res.status(401).json({ error: "API credentials required" });
}
// Verify API credentials
const host = await prisma.hosts.findFirst({
where: {
api_id: apiId,
api_key: apiKey,
},
});
if (!host) {
return res.status(401).json({ error: "Invalid API credentials" });
}
const settings = await prisma.settings.findFirst();
// Return both global and host-specific auto-update settings
res.json({
auto_update: settings?.auto_update || false,
host_auto_update: host.auto_update || false,
});
} catch (error) {
console.error("Get settings error:", error);
res.status(500).json({ error: "Failed to get settings" });
}
});
// Update host friendly name (admin only)
router.patch(
"/:hostId/friendly-name",

View File

@@ -109,7 +109,9 @@ async function triggerCrontabUpdates() {
router.get("/", authenticateToken, requireManageSettings, async (_req, res) => {
try {
const settings = await getSettings();
console.log("Returning settings:", settings);
if (process.env.ENABLE_LOGGING === "true") {
console.log("Returning settings:", settings);
}
res.json(settings);
} catch (error) {
console.error("Settings fetch error:", error);
@@ -239,9 +241,26 @@ router.get("/server-url", async (_req, res) => {
}
});
// Get update interval policy for agents (public endpoint)
router.get("/update-interval", async (_req, res) => {
// Get update interval policy for agents (requires API authentication)
router.get("/update-interval", async (req, res) => {
try {
// Verify API credentials
const apiId = req.headers["x-api-id"];
const apiKey = req.headers["x-api-key"];
if (!apiId || !apiKey) {
return res.status(401).json({ error: "API credentials required" });
}
// Validate API credentials
const host = await prisma.hosts.findUnique({
where: { api_id: apiId },
});
if (!host || host.api_key !== apiKey) {
return res.status(401).json({ error: "Invalid API credentials" });
}
const settings = await getSettings();
res.json({
updateInterval: settings.update_interval,

View File

@@ -14,7 +14,7 @@ const router = express.Router();
router.get("/current", authenticateToken, async (_req, res) => {
try {
// Read version from package.json dynamically
let currentVersion = "1.2.6"; // fallback
let currentVersion = "1.2.7"; // fallback
try {
const packageJson = require("../../package.json");
@@ -174,7 +174,7 @@ router.get(
return res.status(400).json({ error: "Settings not found" });
}
const currentVersion = "1.2.6";
const currentVersion = "1.2.7";
const latestVersion = settings.latest_version || currentVersion;
const isUpdateAvailable = settings.update_available || false;
const lastUpdateCheck = settings.last_update_check || null;

View File

@@ -30,200 +30,6 @@ const { initSettings } = require("./services/settingsService");
// Initialize Prisma client with optimized connection pooling for multiple instances
const prisma = createPrismaClient();
// Simple version comparison function for semantic versioning
function compareVersions(version1, version2) {
const v1Parts = version1.split(".").map(Number);
const v2Parts = version2.split(".").map(Number);
// Ensure both arrays have the same length
const maxLength = Math.max(v1Parts.length, v2Parts.length);
while (v1Parts.length < maxLength) v1Parts.push(0);
while (v2Parts.length < maxLength) v2Parts.push(0);
for (let i = 0; i < maxLength; i++) {
if (v1Parts[i] > v2Parts[i]) return true;
if (v1Parts[i] < v2Parts[i]) return false;
}
return false; // versions are equal
}
// Function to check and import agent version on startup
async function checkAndImportAgentVersion() {
console.log("🔍 Starting agent version auto-import check...");
// Skip if auto-import is disabled
if (process.env.AUTO_IMPORT_AGENT_VERSION === "false") {
console.log("❌ Auto-import of agent version is disabled");
if (process.env.ENABLE_LOGGING === "true") {
logger.info("Auto-import of agent version is disabled");
}
return;
}
try {
const fs = require("node:fs");
const path = require("node:path");
const crypto = require("node:crypto");
// Read and validate agent script
const agentScriptPath = path.join(
__dirname,
"../../agents/patchmon-agent.sh",
);
console.log("📁 Agent script path:", agentScriptPath);
// Check if file exists
if (!fs.existsSync(agentScriptPath)) {
console.log("❌ Agent script file not found, skipping version check");
if (process.env.ENABLE_LOGGING === "true") {
logger.warn("Agent script file not found, skipping version check");
}
return;
}
console.log("✅ Agent script file found");
// Read the file content
const scriptContent = fs.readFileSync(agentScriptPath, "utf8");
// Extract version from script content
const versionMatch = scriptContent.match(/AGENT_VERSION="([^"]+)"/);
if (!versionMatch) {
console.log(
"❌ Could not extract version from agent script, skipping version check",
);
if (process.env.ENABLE_LOGGING === "true") {
logger.warn(
"Could not extract version from agent script, skipping version check",
);
}
return;
}
const localVersion = versionMatch[1];
console.log("📋 Local version:", localVersion);
// Check if this version already exists in database
const existingVersion = await prisma.agent_versions.findUnique({
where: { version: localVersion },
});
if (existingVersion) {
console.log(
`✅ Agent version ${localVersion} already exists in database`,
);
if (process.env.ENABLE_LOGGING === "true") {
logger.info(`Agent version ${localVersion} already exists in database`);
}
return;
}
console.log(`🆕 Agent version ${localVersion} not found in database`);
// Get existing versions for comparison
const allVersions = await prisma.agent_versions.findMany({
select: { version: true },
orderBy: { created_at: "desc" },
});
// Determine version flags and whether to proceed
const isFirstVersion = allVersions.length === 0;
const isNewerVersion =
!isFirstVersion && compareVersions(localVersion, allVersions[0].version);
if (!isFirstVersion && !isNewerVersion) {
console.log(
`❌ Agent version ${localVersion} is not newer than existing versions, skipping import`,
);
if (process.env.ENABLE_LOGGING === "true") {
logger.info(
`Agent version ${localVersion} is not newer than existing versions, skipping import`,
);
}
return;
}
const shouldSetAsCurrent = isFirstVersion || isNewerVersion;
const shouldSetAsDefault = isFirstVersion;
console.log(
isFirstVersion
? `📊 No existing versions found in database`
: `📊 Found ${allVersions.length} existing versions in database, latest: ${allVersions[0].version}`,
);
if (!isFirstVersion) {
console.log(
`🔄 Version comparison: ${localVersion} > ${allVersions[0].version} = ${isNewerVersion}`,
);
}
// Clear existing flags if needed
const updatePromises = [];
if (shouldSetAsCurrent) {
updatePromises.push(
prisma.agent_versions.updateMany({
where: { is_current: true },
data: { is_current: false },
}),
);
}
if (shouldSetAsDefault) {
updatePromises.push(
prisma.agent_versions.updateMany({
where: { is_default: true },
data: { is_default: false },
}),
);
}
if (updatePromises.length > 0) {
await Promise.all(updatePromises);
}
// Create new version
await prisma.agent_versions.create({
data: {
id: crypto.randomUUID(),
version: localVersion,
release_notes: `Auto-imported on startup (${new Date().toISOString()})`,
script_content: scriptContent,
is_default: shouldSetAsDefault,
is_current: shouldSetAsCurrent,
updated_at: new Date(),
},
});
console.log(
`🎉 Successfully auto-imported new agent version ${localVersion} on startup`,
);
if (shouldSetAsCurrent) {
console.log(`✅ Set version ${localVersion} as current version`);
}
if (shouldSetAsDefault) {
console.log(`✅ Set version ${localVersion} as default version`);
}
if (process.env.ENABLE_LOGGING === "true") {
logger.info(
`✅ Auto-imported new agent version ${localVersion} on startup (current: ${shouldSetAsCurrent}, default: ${shouldSetAsDefault})`,
);
}
} catch (error) {
console.error(
"❌ Failed to check/import agent version on startup:",
error.message,
);
if (process.env.ENABLE_LOGGING === "true") {
logger.error(
"Failed to check/import agent version on startup:",
error.message,
);
}
}
}
// Function to check and create default role permissions on startup
async function checkAndCreateRolePermissions() {
console.log("🔐 Starting role permissions auto-creation check...");
@@ -610,8 +416,6 @@ process.on("SIGTERM", async () => {
// Initialize dashboard preferences for all users
async function initializeDashboardPreferences() {
try {
console.log("🔧 Initializing dashboard preferences for all users...");
// Get all users
const users = await prisma.users.findMany({
select: {
@@ -628,12 +432,9 @@ async function initializeDashboardPreferences() {
});
if (users.length === 0) {
console.log(" No users found in database");
return;
}
console.log(`📊 Found ${users.length} users to initialize`);
let initializedCount = 0;
let updatedCount = 0;
@@ -648,10 +449,6 @@ async function initializeDashboardPreferences() {
if (!hasPreferences) {
// User has no preferences - create them
console.log(
`⚙️ Creating preferences for ${user.username} (${user.role})`,
);
const preferencesData = expectedPreferences.map((pref) => ({
id: require("uuid").v4(),
user_id: user.id,
@@ -667,18 +464,11 @@ async function initializeDashboardPreferences() {
});
initializedCount++;
console.log(
` ✅ Created ${expectedCardCount} cards based on permissions`,
);
} else {
// User already has preferences - check if they need updating
const currentCardCount = user.dashboard_preferences.length;
if (currentCardCount !== expectedCardCount) {
console.log(
`🔄 Updating preferences for ${user.username} (${user.role}) - ${currentCardCount}${expectedCardCount} cards`,
);
// Delete existing preferences
await prisma.dashboard_preferences.deleteMany({
where: { user_id: user.id },
@@ -700,29 +490,16 @@ async function initializeDashboardPreferences() {
});
updatedCount++;
console.log(
` ✅ Updated to ${expectedCardCount} cards based on permissions`,
);
} else {
console.log(
`${user.username} already has correct preferences (${currentCardCount} cards)`,
);
}
}
}
console.log(`\n📋 Dashboard Preferences Initialization Complete:`);
console.log(` - New users initialized: ${initializedCount}`);
console.log(` - Existing users updated: ${updatedCount}`);
console.log(
` - Users with correct preferences: ${users.length - initializedCount - updatedCount}`,
);
console.log(`\n🎯 Permission-based preferences:`);
console.log(` - Cards are now assigned based on actual user permissions`);
console.log(
` - Each card requires specific permissions (can_view_hosts, can_view_users, etc.)`,
);
console.log(` - Users only see cards they have permission to access`);
// Only show summary if there were changes
if (initializedCount > 0 || updatedCount > 0) {
console.log(
`✅ Dashboard preferences: ${initializedCount} initialized, ${updatedCount} updated`,
);
}
} catch (error) {
console.error("❌ Error initializing dashboard preferences:", error);
throw error;
@@ -889,9 +666,6 @@ async function startServer() {
throw initError; // Fail startup if settings can't be initialised
}
// Check and import agent version on startup
await checkAndImportAgentVersion();
// Check and create default role permissions on startup
await checkAndCreateRolePermissions();

View File

@@ -72,9 +72,11 @@ async function syncEnvironmentToSettings(currentSettings) {
if (currentValue !== convertedValue) {
updates[settingsField] = convertedValue;
hasChanges = true;
console.log(
`Environment variable ${envVar} (${envValue}) differs from settings ${settingsField} (${currentValue}), updating...`,
);
if (process.env.ENABLE_LOGGING === "true") {
console.log(
`Environment variable ${envVar} (${envValue}) differs from settings ${settingsField} (${currentValue}), updating...`,
);
}
}
}
}
@@ -89,7 +91,9 @@ async function syncEnvironmentToSettings(currentSettings) {
if (currentSettings.server_url !== constructedServerUrl) {
updates.server_url = constructedServerUrl;
hasChanges = true;
console.log(`Updating server_url to: ${constructedServerUrl}`);
if (process.env.ENABLE_LOGGING === "true") {
console.log(`Updating server_url to: ${constructedServerUrl}`);
}
}
// Update settings if there are changes
@@ -101,9 +105,11 @@ async function syncEnvironmentToSettings(currentSettings) {
updated_at: new Date(),
},
});
console.log(
`Synced ${Object.keys(updates).length} environment variables to settings`,
);
if (process.env.ENABLE_LOGGING === "true") {
console.log(
`Synced ${Object.keys(updates).length} environment variables to settings`,
);
}
return updatedSettings;
}

View File

@@ -109,7 +109,7 @@ class UpdateScheduler {
}
// Read version from package.json dynamically
let currentVersion = "1.2.6"; // fallback
let currentVersion = "1.2.7"; // fallback
try {
const packageJson = require("../../package.json");
if (packageJson?.version) {
@@ -219,7 +219,7 @@ class UpdateScheduler {
const httpsRepoUrl = `https://api.github.com/repos/${owner}/${repo}/releases/latest`;
// Get current version for User-Agent
let currentVersion = "1.2.6"; // fallback
let currentVersion = "1.2.7"; // fallback
try {
const packageJson = require("../../package.json");
if (packageJson?.version) {

View File

@@ -1,7 +1,7 @@
{
"name": "patchmon-frontend",
"private": true,
"version": "1.2.6",
"version": "1.2.7",
"license": "AGPL-3.0",
"type": "module",
"scripts": {

View File

@@ -800,7 +800,7 @@ const Layout = ({ children }) => {
)}
</a>
<a
href="https://discord.gg/DDKQeW6mnq"
href="https://patchmon.net/discord"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center w-10 h-10 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm"

View File

@@ -1083,13 +1083,13 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
One-Line Installation
</h4>
<p className="text-sm text-primary-700 dark:text-primary-300 mb-3">
Copy and run this command on the target host to automatically
install and configure the PatchMon agent:
Copy and run this command on the target host to securely install
and configure the PatchMon agent:
</p>
<div className="flex items-center gap-2">
<input
type="text"
value={`curl -s ${serverUrl}/api/v1/hosts/install | bash -s -- ${serverUrl} "${host.api_id}" "${host.api_key}"`}
value={`curl -ks ${serverUrl}/api/v1/hosts/install -H "X-API-ID: ${host.api_id}" -H "X-API-KEY: ${host.api_key}" | bash`}
readOnly
className="flex-1 px-3 py-2 border border-primary-300 dark:border-primary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
/>
@@ -1097,7 +1097,7 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
type="button"
onClick={() =>
copyToClipboard(
`curl -s ${serverUrl}/api/v1/hosts/install | bash -s -- ${serverUrl} "${host.api_id}" "${host.api_key}"`,
`curl -ks ${serverUrl}/api/v1/hosts/install -H "X-API-ID: ${host.api_id}" -H "X-API-KEY: ${host.api_key}" | bash`,
)
}
className="btn-primary flex items-center gap-1"
@@ -1118,21 +1118,19 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
<div className="space-y-3">
<div className="bg-white dark:bg-secondary-800 rounded-md p-3 border border-secondary-200 dark:border-secondary-600">
<h5 className="text-sm font-medium text-secondary-900 dark:text-white mb-2">
1. Download Agent Script
1. Create Configuration Directory
</h5>
<div className="flex items-center gap-2">
<input
type="text"
value={`curl -o /tmp/patchmon-agent.sh ${serverUrl}/api/v1/hosts/agent/download`}
value="sudo mkdir -p /etc/patchmon"
readOnly
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
/>
<button
type="button"
onClick={() =>
copyToClipboard(
`curl -o /tmp/patchmon-agent.sh ${serverUrl}/api/v1/hosts/agent/download`,
)
copyToClipboard("sudo mkdir -p /etc/patchmon")
}
className="btn-secondary flex items-center gap-1"
>
@@ -1144,12 +1142,12 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
<div className="bg-white dark:bg-secondary-800 rounded-md p-3 border border-secondary-200 dark:border-secondary-600">
<h5 className="text-sm font-medium text-secondary-900 dark:text-white mb-2">
2. Install Agent
2. Download and Install Agent Script
</h5>
<div className="flex items-center gap-2">
<input
type="text"
value="sudo mkdir -p /etc/patchmon && sudo mv /tmp/patchmon-agent.sh /usr/local/bin/patchmon-agent.sh && sudo chmod +x /usr/local/bin/patchmon-agent.sh"
value={`curl -ko /usr/local/bin/patchmon-agent.sh ${serverUrl}/api/v1/hosts/agent/download -H "X-API-ID: ${host.api_id}" -H "X-API-KEY: ${host.api_key}" && sudo chmod +x /usr/local/bin/patchmon-agent.sh`}
readOnly
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
/>
@@ -1157,7 +1155,7 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
type="button"
onClick={() =>
copyToClipboard(
"sudo mkdir -p /etc/patchmon && sudo mv /tmp/patchmon-agent.sh /usr/local/bin/patchmon-agent.sh && sudo chmod +x /usr/local/bin/patchmon-agent.sh",
`curl -ko /usr/local/bin/patchmon-agent.sh ${serverUrl}/api/v1/hosts/agent/download -H "X-API-ID: ${host.api_id}" -H "X-API-KEY: ${host.api_key}" && sudo chmod +x /usr/local/bin/patchmon-agent.sh`,
)
}
className="btn-secondary flex items-center gap-1"
@@ -1175,7 +1173,7 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
<div className="flex items-center gap-2">
<input
type="text"
value={`sudo /usr/local/bin/patchmon-agent.sh configure "${host.api_id}" "${host.api_key}"`}
value={`sudo /usr/local/bin/patchmon-agent.sh configure "${host.api_id}" "${host.api_key}" "${serverUrl}"`}
readOnly
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
/>
@@ -1183,7 +1181,7 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
type="button"
onClick={() =>
copyToClipboard(
`sudo /usr/local/bin/patchmon-agent.sh configure "${host.api_id}" "${host.api_key}"`,
`sudo /usr/local/bin/patchmon-agent.sh configure "${host.api_id}" "${host.api_key}" "${serverUrl}"`,
)
}
className="btn-secondary flex items-center gap-1"
@@ -1253,7 +1251,7 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
<div className="flex items-center gap-2">
<input
type="text"
value={`echo "${new Date().getMinutes()} * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" | sudo crontab -`}
value={`(sudo crontab -l 2>/dev/null | grep -v "patchmon-agent.sh update"; echo "${new Date().getMinutes()} * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1") | sudo crontab -`}
readOnly
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
/>
@@ -1261,7 +1259,7 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
type="button"
onClick={() =>
copyToClipboard(
`echo "${new Date().getMinutes()} * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" | sudo crontab -`,
`(sudo crontab -l 2>/dev/null | grep -v "patchmon-agent.sh update"; echo "${new Date().getMinutes()} * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1") | sudo crontab -`,
)
}
className="btn-secondary flex items-center gap-1"

View File

@@ -10,8 +10,6 @@ import {
Server,
Settings as SettingsIcon,
Shield,
Star,
Trash2,
X,
} from "lucide-react";
@@ -19,23 +17,15 @@ import { useEffect, useId, useState } from "react";
import UpgradeNotificationIcon from "../components/UpgradeNotificationIcon";
import { useUpdateNotification } from "../contexts/UpdateNotificationContext";
import {
agentVersionAPI,
agentFileAPI,
permissionsAPI,
settingsAPI,
versionAPI,
} from "../utils/api";
const Settings = () => {
const repoPublicId = useId();
const repoPrivateId = useId();
const useCustomSshKeyId = useId();
const protocolId = useId();
const hostId = useId();
const portId = useId();
const updateIntervalId = useId();
const defaultRoleId = useId();
const githubRepoUrlId = useId();
const sshKeyPathId = useId();
const _scriptFileId = useId();
const _scriptContentId = useId();
const [formData, setFormData] = useState({
serverProtocol: "http",
serverHost: "localhost",
@@ -70,8 +60,15 @@ const Settings = () => {
},
];
// Agent version management state
const [showAgentVersionModal, setShowAgentVersionModal] = useState(false);
// Agent management state
const [_agentInfo, _setAgentInfo] = useState({
version: null,
lastModified: null,
size: null,
loading: true,
error: null,
});
const [showUploadModal, setShowUploadModal] = useState(false);
// Version checking state
const [versionInfo, setVersionInfo] = useState({
@@ -157,17 +154,26 @@ const Settings = () => {
},
});
// Agent version queries and mutations
// Agent file queries and mutations
const {
data: agentVersions,
isLoading: agentVersionsLoading,
error: agentVersionsError,
data: agentFileInfo,
isLoading: agentFileLoading,
error: agentFileError,
refetch: refetchAgentFile,
} = useQuery({
queryKey: ["agentVersions"],
queryFn: () => {
return agentVersionAPI.list().then((res) => {
return res.data;
});
queryKey: ["agentFile"],
queryFn: () => agentFileAPI.getInfo().then((res) => res.data),
});
const uploadAgentMutation = useMutation({
mutationFn: (scriptContent) =>
agentFileAPI.upload(scriptContent).then((res) => res.data),
onSuccess: () => {
refetchAgentFile();
setShowUploadModal(false);
},
onError: (error) => {
console.error("Upload agent error:", error);
},
});
@@ -189,63 +195,6 @@ const Settings = () => {
loadCurrentVersion();
}, []);
const createAgentVersionMutation = useMutation({
mutationFn: (data) => agentVersionAPI.create(data).then((res) => res.data),
onSuccess: () => {
queryClient.invalidateQueries(["agentVersions"]);
setShowAgentVersionModal(false);
setAgentVersionForm({
version: "",
releaseNotes: "",
scriptContent: "",
isDefault: false,
});
},
});
const setCurrentAgentVersionMutation = useMutation({
mutationFn: (id) => agentVersionAPI.setCurrent(id).then((res) => res.data),
onSuccess: () => {
queryClient.invalidateQueries(["agentVersions"]);
},
});
const setDefaultAgentVersionMutation = useMutation({
mutationFn: (id) => agentVersionAPI.setDefault(id).then((res) => res.data),
onSuccess: () => {
queryClient.invalidateQueries(["agentVersions"]);
},
});
const deleteAgentVersionMutation = useMutation({
mutationFn: (id) => agentVersionAPI.delete(id).then((res) => res.data),
onSuccess: () => {
queryClient.invalidateQueries(["agentVersions"]);
},
onError: (error) => {
console.error("Delete agent version error:", error);
// Show user-friendly error message
if (error.response?.data?.error === "Agent version not found") {
alert(
"Agent version not found. Please refresh the page to get the latest data.",
);
// Force refresh the agent versions list
queryClient.invalidateQueries(["agentVersions"]);
} else if (
error.response?.data?.error === "Cannot delete current agent version"
) {
alert(
"Cannot delete the current agent version. Please set another version as current first.",
);
} else {
alert(
`Failed to delete agent version: ${error.response?.data?.error || error.message}`,
);
}
},
});
// Version checking functions
const checkForUpdates = async () => {
setVersionInfo((prev) => ({ ...prev, checking: true, error: null }));
@@ -813,180 +762,164 @@ const Settings = () => {
<div className="flex items-center mb-2">
<SettingsIcon className="h-6 w-6 text-primary-600 mr-3" />
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
Agent Version Management
Agent File Management
</h2>
</div>
<p className="text-sm text-secondary-500 dark:text-secondary-300">
Manage different versions of the PatchMon agent script
Manage the PatchMon agent script file used for installations
and updates
</p>
</div>
<button
type="button"
onClick={() => setShowAgentVersionModal(true)}
className="btn-primary flex items-center gap-2"
>
<Plus className="h-4 w-4" />
Add Version
</button>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
const url = "/api/v1/hosts/agent/download";
const link = document.createElement("a");
link.href = url;
link.download = "patchmon-agent.sh";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}}
className="btn-outline flex items-center gap-2"
>
<Download className="h-4 w-4" />
Download
</button>
<button
type="button"
onClick={() => setShowUploadModal(true)}
className="btn-primary flex items-center gap-2"
>
<Plus className="h-4 w-4" />
Replace Script
</button>
</div>
</div>
{/* Version Summary */}
{agentVersions && agentVersions.length > 0 && (
<div className="bg-secondary-50 dark:bg-secondary-700 rounded-lg p-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex items-center gap-2">
<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400" />
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
Current Version:
</span>
<span className="text-sm text-secondary-900 dark:text-white font-mono">
{agentVersions.find((v) => v.is_current)?.version ||
"None"}
</span>
</div>
<div className="flex items-center gap-2">
<Star className="h-4 w-4 text-yellow-600 dark:text-yellow-400" />
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
Default Version:
</span>
<span className="text-sm text-secondary-900 dark:text-white font-mono">
{agentVersions.find((v) => v.is_default)?.version ||
"None"}
</span>
</div>
</div>
</div>
)}
{agentVersionsLoading ? (
{agentFileLoading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
) : agentVersionsError ? (
) : agentFileError ? (
<div className="text-center py-8">
<p className="text-red-600 dark:text-red-400">
Error loading agent versions: {agentVersionsError.message}
Error loading agent file: {agentFileError.message}
</p>
</div>
) : !agentVersions || agentVersions.length === 0 ? (
) : !agentFileInfo?.exists ? (
<div className="text-center py-8">
<p className="text-secondary-500 dark:text-secondary-400">
No agent versions found
<Code className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<p className="text-secondary-500 dark:text-secondary-300">
No agent script found
</p>
<p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2">
Upload an agent script to get started
</p>
</div>
) : (
<div className="space-y-4">
{agentVersions.map((version) => (
<div
key={version.id}
className="border border-secondary-200 dark:border-secondary-600 rounded-lg p-4"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Code className="h-5 w-5 text-secondary-400 dark:text-secondary-500" />
<div>
<div className="flex items-center gap-2">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Version {version.version}
</h3>
{version.is_default && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200">
<Star className="h-3 w-3 mr-1" />
Default
</span>
)}
{version.is_current && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Current
</span>
)}
</div>
{version.release_notes && (
<div className="text-sm text-secondary-500 dark:text-secondary-300 mt-1">
<p className="line-clamp-3 whitespace-pre-line">
{version.release_notes}
</p>
</div>
)}
<p className="text-xs text-secondary-400 dark:text-secondary-400 mt-1">
Created:{" "}
{new Date(
version.created_at,
).toLocaleDateString()}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
const downloadUrl = `/api/v1/hosts/agent/download?version=${version.version}`;
window.open(downloadUrl, "_blank");
}}
className="btn-outline text-xs flex items-center gap-1"
>
<Download className="h-3 w-3" />
Download
</button>
<button
type="button"
onClick={() =>
setCurrentAgentVersionMutation.mutate(version.id)
}
disabled={
version.is_current ||
setCurrentAgentVersionMutation.isPending
}
className="btn-outline text-xs flex items-center gap-1"
>
<CheckCircle className="h-3 w-3" />
Set Current
</button>
<button
type="button"
onClick={() =>
setDefaultAgentVersionMutation.mutate(version.id)
}
disabled={
version.is_default ||
setDefaultAgentVersionMutation.isPending
}
className="btn-outline text-xs flex items-center gap-1"
>
<Star className="h-3 w-3" />
Set Default
</button>
<button
type="button"
onClick={() =>
deleteAgentVersionMutation.mutate(version.id)
}
disabled={
version.is_default ||
version.is_current ||
deleteAgentVersionMutation.isPending
}
className="btn-danger text-xs flex items-center gap-1"
>
<Trash2 className="h-3 w-3" />
Delete
</button>
<div className="space-y-6">
{/* Agent File Info */}
<div className="bg-secondary-50 dark:bg-secondary-700 rounded-lg p-6">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
Current Agent Script
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="flex items-center gap-2">
<Code className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
Version:
</span>
<span className="text-sm text-secondary-900 dark:text-white font-mono">
{agentFileInfo.version}
</span>
</div>
<div className="flex items-center gap-2">
<Download className="h-4 w-4 text-green-600 dark:text-green-400" />
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
Size:
</span>
<span className="text-sm text-secondary-900 dark:text-white">
{agentFileInfo.sizeFormatted}
</span>
</div>
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-yellow-600 dark:text-yellow-400" />
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
Modified:
</span>
<span className="text-sm text-secondary-900 dark:text-white">
{new Date(
agentFileInfo.lastModified,
).toLocaleDateString()}
</span>
</div>
</div>
</div>
{/* Usage Instructions */}
<div className="bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-md p-4">
<div className="flex">
<Shield className="h-5 w-5 text-blue-400 dark:text-blue-300" />
<div className="ml-3">
<h3 className="text-sm font-medium text-blue-800 dark:text-blue-200">
Agent Script Usage
</h3>
<div className="mt-2 text-sm text-blue-700 dark:text-blue-300">
<p className="mb-2">This script is used for:</p>
<ul className="list-disc list-inside space-y-1">
<li>
New agent installations via the install script
</li>
<li>
Agent downloads from the
/api/v1/hosts/agent/download endpoint
</li>
<li>Manual agent deployments and updates</li>
</ul>
</div>
</div>
</div>
))}
</div>
{agentVersions?.length === 0 && (
<div className="text-center py-8">
<Code className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<p className="text-secondary-500 dark:text-secondary-300">
No agent versions found
</p>
<p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2">
Add your first agent version to get started
</p>
{/* Uninstall Instructions */}
<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4">
<div className="flex">
<Shield className="h-5 w-5 text-red-400 dark:text-red-300" />
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
Agent Uninstall Command
</h3>
<div className="mt-2 text-sm text-red-700 dark:text-red-300">
<p className="mb-2">
To completely remove PatchMon from a host:
</p>
<div className="flex items-center gap-2">
<div className="bg-red-100 dark:bg-red-800 rounded p-2 font-mono text-xs flex-1">
curl -ks {window.location.origin}
/api/v1/hosts/remove | sudo bash
</div>
<button
type="button"
onClick={() => {
const command = `curl -ks ${window.location.origin}/api/v1/hosts/remove | sudo bash`;
navigator.clipboard.writeText(command);
// You could add a toast notification here
}}
className="px-2 py-1 bg-red-200 dark:bg-red-700 text-red-800 dark:text-red-200 rounded text-xs hover:bg-red-300 dark:hover:bg-red-600 transition-colors"
>
Copy
</button>
</div>
<p className="mt-2 text-xs">
This will remove all PatchMon files,
configuration, and crontab entries
</p>
</div>
</div>
</div>
)}
</div>
</div>
)}
</div>
@@ -1330,52 +1263,42 @@ const Settings = () => {
</div>
</div>
{/* Agent Version Modal */}
{showAgentVersionModal && (
<AgentVersionModal
isOpen={showAgentVersionModal}
onClose={() => {
setShowAgentVersionModal(false);
setAgentVersionForm({
version: "",
releaseNotes: "",
scriptContent: "",
isDefault: false,
});
}}
onSubmit={createAgentVersionMutation.mutate}
isLoading={createAgentVersionMutation.isPending}
{/* Agent Upload Modal */}
{showUploadModal && (
<AgentUploadModal
isOpen={showUploadModal}
onClose={() => setShowUploadModal(false)}
onSubmit={uploadAgentMutation.mutate}
isLoading={uploadAgentMutation.isPending}
error={uploadAgentMutation.error}
/>
)}
</div>
);
};
// Agent Version Modal Component
const AgentVersionModal = ({ isOpen, onClose, onSubmit, isLoading }) => {
const [formData, setFormData] = useState({
version: "",
releaseNotes: "",
scriptContent: "",
isDefault: false,
});
const [errors, setErrors] = useState({});
// Agent Upload Modal Component
const AgentUploadModal = ({ isOpen, onClose, onSubmit, isLoading, error }) => {
const [scriptContent, setScriptContent] = useState("");
const [uploadError, setUploadError] = useState("");
const handleSubmit = (e) => {
e.preventDefault();
setUploadError("");
// Basic validation
const newErrors = {};
if (!formData.version.trim()) newErrors.version = "Version is required";
if (!formData.scriptContent.trim())
newErrors.scriptContent = "Script content is required";
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
if (!scriptContent.trim()) {
setUploadError("Script content is required");
return;
}
onSubmit(formData);
if (!scriptContent.trim().startsWith("#!/")) {
setUploadError(
"Script must start with a shebang (#!/bin/bash or #!/bin/sh)",
);
return;
}
onSubmit(scriptContent);
};
const handleFileUpload = (e) => {
@@ -1383,10 +1306,8 @@ const AgentVersionModal = ({ isOpen, onClose, onSubmit, isLoading }) => {
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
setFormData((prev) => ({
...prev,
scriptContent: event.target.result,
}));
setScriptContent(event.target.result);
setUploadError("");
};
reader.readAsText(file);
}
@@ -1396,11 +1317,11 @@ const AgentVersionModal = ({ isOpen, onClose, onSubmit, isLoading }) => {
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Add Agent Version
Replace Agent Script
</h3>
<button
type="button"
@@ -1416,112 +1337,69 @@ const AgentVersionModal = ({ isOpen, onClose, onSubmit, isLoading }) => {
<div className="space-y-4">
<div>
<label
htmlFor={versionId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
htmlFor={scriptFileId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
>
Version *
Upload Script File
</label>
<input
id={versionId}
type="text"
value={formData.version}
onChange={(e) =>
setFormData((prev) => ({ ...prev, version: e.target.value }))
}
className={`block w-full border rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white ${
errors.version
? "border-red-300 dark:border-red-500"
: "border-secondary-300 dark:border-secondary-600"
}`}
placeholder="e.g., 1.0.1"
/>
{errors.version && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">
{errors.version}
</p>
)}
</div>
<div>
<label
htmlFor={releaseNotesId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Release Notes
</label>
<textarea
id={releaseNotesId}
value={formData.releaseNotes}
onChange={(e) =>
setFormData((prev) => ({
...prev,
releaseNotes: e.target.value,
}))
}
rows={3}
className="block w-full border border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
placeholder="Describe what's new in this version..."
id={scriptFileId}
type="file"
accept=".sh"
onChange={handleFileUpload}
className="block w-full text-sm text-secondary-500 dark:text-secondary-400 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100 dark:file:bg-primary-900 dark:file:text-primary-200"
/>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Select a .sh file to upload, or paste the script content below
</p>
</div>
<div>
<label
htmlFor={scriptContentId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
>
Script Content *
</label>
<div className="space-y-2">
<input
type="file"
accept=".sh"
onChange={handleFileUpload}
className="block w-full text-sm text-secondary-500 dark:text-secondary-400 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100 dark:file:bg-primary-900 dark:file:text-primary-200"
/>
<textarea
id={scriptContentId}
value={formData.scriptContent}
onChange={(e) =>
setFormData((prev) => ({
...prev,
scriptContent: e.target.value,
}))
}
rows={10}
className={`block w-full border rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white font-mono text-sm ${
errors.scriptContent
? "border-red-300 dark:border-red-500"
: "border-secondary-300 dark:border-secondary-600"
}`}
placeholder="Paste the agent script content here..."
/>
{errors.scriptContent && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">
{errors.scriptContent}
</p>
)}
</div>
<textarea
id={scriptContentId}
value={scriptContent}
onChange={(e) => {
setScriptContent(e.target.value);
setUploadError("");
}}
rows={15}
className="block w-full border border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white font-mono text-sm"
placeholder="#!/bin/bash&#10;&#10;# PatchMon Agent Script&#10;VERSION=&quot;1.0.0&quot;&#10;&#10;# Your script content here..."
/>
</div>
<div className="flex items-center">
<input
type="checkbox"
id={isDefaultId}
checked={formData.isDefault}
onChange={(e) =>
setFormData((prev) => ({
...prev,
isDefault: e.target.checked,
}))
}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-600 rounded"
/>
<label
htmlFor={isDefaultId}
className="ml-2 block text-sm text-secondary-700 dark:text-secondary-200"
>
Set as default version for new installations
</label>
{(uploadError || error) && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md p-3">
<p className="text-sm text-red-800 dark:text-red-200">
{uploadError ||
error?.response?.data?.error ||
error?.message}
</p>
</div>
)}
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md p-3">
<div className="flex">
<AlertCircle className="h-4 w-4 text-yellow-600 dark:text-yellow-400 mr-2 mt-0.5" />
<div className="text-sm text-yellow-800 dark:text-yellow-200">
<p className="font-medium">Important:</p>
<ul className="mt-1 list-disc list-inside space-y-1">
<li>This will replace the current agent script file</li>
<li>A backup will be created automatically</li>
<li>All new installations will use this script</li>
<li>
Existing agents will download this version on their next
update
</li>
</ul>
</div>
</div>
</div>
</div>
@@ -1529,8 +1407,12 @@ const AgentVersionModal = ({ isOpen, onClose, onSubmit, isLoading }) => {
<button type="button" onClick={onClose} className="btn-outline">
Cancel
</button>
<button type="submit" disabled={isLoading} className="btn-primary">
{isLoading ? "Creating..." : "Create Version"}
<button
type="submit"
disabled={isLoading || !scriptContent.trim()}
className="btn-primary"
>
{isLoading ? "Uploading..." : "Replace Script"}
</button>
</div>
</form>

View File

@@ -114,18 +114,11 @@ export const settingsAPI = {
getServerUrl: () => api.get("/settings/server-url"),
};
// Agent Version API
export const agentVersionAPI = {
list: () => api.get("/hosts/agent/versions"),
create: (data) => api.post("/hosts/agent/versions", data),
update: (id, data) => api.put(`/hosts/agent/versions/${id}`, data),
delete: (id) => api.delete(`/hosts/agent/versions/${id}`),
setCurrent: (id) => api.patch(`/hosts/agent/versions/${id}/current`),
setDefault: (id) => api.patch(`/hosts/agent/versions/${id}/default`),
download: (version) =>
api.get(`/hosts/agent/download${version ? `?version=${version}` : ""}`, {
responseType: "blob",
}),
// Agent File Management API
export const agentFileAPI = {
getInfo: () => api.get("/hosts/agent/info"),
upload: (scriptContent) => api.post("/hosts/agent/upload", { scriptContent }),
download: () => api.get("/hosts/agent/download", { responseType: "blob" }),
};
// Repository API

8
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "patchmon",
"version": "1.2.6",
"version": "1.2.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "patchmon",
"version": "1.2.6",
"version": "1.2.7",
"license": "AGPL-3.0",
"workspaces": [
"backend",
@@ -23,7 +23,7 @@
},
"backend": {
"name": "patchmon-backend",
"version": "1.2.6",
"version": "1.2.7",
"license": "AGPL-3.0",
"dependencies": {
"@prisma/client": "^6.1.0",
@@ -52,7 +52,7 @@
},
"frontend": {
"name": "patchmon-frontend",
"version": "1.2.6",
"version": "1.2.7",
"license": "AGPL-3.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",

View File

@@ -1,6 +1,6 @@
{
"name": "patchmon",
"version": "1.2.6",
"version": "1.2.7",
"description": "Linux Patch Monitoring System",
"license": "AGPL-3.0",
"private": true,

101
setup.sh
View File

@@ -34,7 +34,7 @@ BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Global variables
SCRIPT_VERSION="self-hosting-install.sh v1.2.6-selfhost-2025-01-20-1"
SCRIPT_VERSION="self-hosting-install.sh v1.2.7-selfhost-2025-01-20-1"
DEFAULT_GITHUB_REPO="https://github.com/9technologygroup/patchmon.net.git"
FQDN=""
CUSTOM_FQDN=""
@@ -819,7 +819,7 @@ EOF
cat > frontend/.env << EOF
VITE_API_URL=$SERVER_PROTOCOL_SEL://$FQDN/api/v1
VITE_APP_NAME=PatchMon
VITE_APP_VERSION=1.2.6
VITE_APP_VERSION=1.2.7
EOF
print_status "Environment files created"
@@ -1191,7 +1191,7 @@ create_agent_version() {
# Priority 2: Use fallback version if not found
if [ "$current_version" = "N/A" ] || [ -z "$current_version" ]; then
current_version="1.2.6"
current_version="1.2.7"
print_warning "Could not determine version, using fallback: $current_version"
fi
@@ -1208,100 +1208,7 @@ create_agent_version() {
if [ -f "$APP_DIR/agents/patchmon-agent.sh" ]; then
cp "$APP_DIR/agents/patchmon-agent.sh" "$APP_DIR/backend/"
# Create agent version using Node.js script
node -e "
require('dotenv').config();
const { PrismaClient } = require('@prisma/client');
const fs = require('fs');
const crypto = require('crypto');
async function createAgentVersion() {
let prisma;
try {
// Initialize Prisma client with proper error handling
prisma = new PrismaClient();
// Test database connection
await prisma.\$connect();
console.log('✅ Database connection established');
// Debug: Check what models are available
console.log('Available Prisma models:', Object.keys(prisma).filter(key => !key.startsWith('\$')));
// Check if agent_versions model exists
if (!prisma.agent_versions) {
console.log('❌ agent_versions model not found in Prisma client');
console.log('Available models:', Object.keys(prisma).filter(key => !key.startsWith('\$')));
console.log('Skipping agent version creation...');
return;
}
const currentVersion = '$current_version';
const agentScript = fs.readFileSync('./patchmon-agent.sh', 'utf8');
// Check if current version already exists
const existingVersion = await prisma.agent_versions.findUnique({
where: { version: currentVersion }
});
if (existingVersion) {
// Version exists, always update the script content during updates
console.log('📝 Updating existing agent version ' + currentVersion + ' with latest script content...');
await prisma.agent_versions.update({
where: { version: currentVersion },
data: {
script_content: agentScript,
is_current: true,
is_default: true,
release_notes: 'Version ' + currentVersion + ' - Initial Deployment\\n\\nThis version contains the latest agent script from the deployment.'
}
});
console.log('✅ Agent version ' + currentVersion + ' updated successfully with latest script');
} else {
// Version doesn't exist, create it
console.log('🆕 Creating new agent version ' + currentVersion + '...');
await prisma.agent_versions.create({
data: {
id: crypto.randomUUID(),
version: currentVersion,
script_content: agentScript,
is_current: true,
is_default: true,
release_notes: 'Version ' + currentVersion + ' - Initial Deployment\\n\\nThis version contains the latest agent script from the deployment.',
updated_at: new Date()
}
});
console.log('✅ Agent version ' + currentVersion + ' created successfully');
}
// Set all other versions to not be current/default
await prisma.agent_versions.updateMany({
where: { version: { not: currentVersion } },
data: { is_current: false, is_default: false }
});
console.log('✅ Agent version management completed successfully');
} catch (error) {
console.error('❌ Error creating agent version:', error.message);
console.error('❌ Error details:', error);
process.exit(1);
} finally {
if (prisma) {
await prisma.\$disconnect();
}
}
}
createAgentVersion();
"
# Clean up
rm -f "$APP_DIR/backend/patchmon-agent.sh"
print_status "Agent version created"
else
print_warning "Agent script not found, skipping agent version creation"
fi
print_status "Agent version management removed - using file-based approach"
}
# Create deployment summary