Update PatchMon version to 1.2.7

- Updated agent script version to 1.2.7
- Updated all package.json files to version 1.2.7
- Updated backend version references
- Updated setup script version references
- Fixed agent file path issues in API endpoints
- Fixed linting issues (Node.js imports, unused variables, accessibility)
- Created comprehensive version update guide in patchmon-admin/READMEs/
This commit is contained in:
Muhammad Ibrahim
2025-09-29 20:42:14 +01:00
parent 49c02a54dc
commit b49ea6b197
20 changed files with 1197 additions and 1238 deletions

View File

@@ -1,12 +1,12 @@
#!/bin/bash #!/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 # This script sends package update information to the PatchMon server using API credentials
# Configuration # Configuration
PATCHMON_SERVER="${PATCHMON_SERVER:-http://localhost:3001}" PATCHMON_SERVER="${PATCHMON_SERVER:-http://localhost:3001}"
API_VERSION="v1" API_VERSION="v1"
AGENT_VERSION="1.2.6" AGENT_VERSION="1.2.7"
CONFIG_FILE="/etc/patchmon/agent.conf" CONFIG_FILE="/etc/patchmon/agent.conf"
CREDENTIALS_FILE="/etc/patchmon/credentials" CREDENTIALS_FILE="/etc/patchmon/credentials"
LOG_FILE="/var/log/patchmon-agent.log" LOG_FILE="/var/log/patchmon-agent.log"
@@ -144,7 +144,7 @@ EOF
test_credentials() { test_credentials() {
load_credentials load_credentials
local response=$(curl -ksv -X POST \ local response=$(curl -ks -X POST \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-H "X-API-ID: $API_ID" \ -H "X-API-ID: $API_ID" \
-H "X-API-KEY: $API_KEY" \ -H "X-API-KEY: $API_KEY" \
@@ -809,7 +809,7 @@ EOF
local payload=$(echo "$base_payload $merged_json" | jq -s '.[0] * .[1]') 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 "Content-Type: application/json" \
-H "X-API-ID: $API_ID" \ -H "X-API-ID: $API_ID" \
-H "X-API-KEY: $API_KEY" \ -H "X-API-KEY: $API_KEY" \
@@ -821,25 +821,18 @@ EOF
success "Update sent successfully" success "Update sent successfully"
echo "$response" | grep -o '"packagesProcessed":[0-9]*' | cut -d':' -f2 | xargs -I {} info "Processed {} packages" 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) # Check if auto-update is enabled and check for agent updates locally
if echo "$response" | grep -q '"autoUpdate":{'; then if check_auto_update_enabled; then
local auto_update_section=$(echo "$response" | grep -o '"autoUpdate":{[^}]*}') info "Auto-update is enabled, checking for agent updates..."
local should_update=$(echo "$auto_update_section" | grep -o '"shouldUpdate":true' | cut -d':' -f2) if check_agent_update_needed; then
if [[ "$should_update" == "true" ]]; then info "Agent update available, automatically updating..."
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..."
if "$0" update-agent; then if "$0" update-agent; then
success "PatchMon agent update completed successfully" success "PatchMon agent update completed successfully"
else else
warning "PatchMon agent update failed, but data was sent successfully" warning "PatchMon agent update failed, but data was sent successfully"
fi fi
else
info "Agent is up to date"
fi fi
fi fi
@@ -877,7 +870,7 @@ EOF
ping_server() { ping_server() {
load_credentials load_credentials
local response=$(curl -ksv -X POST \ local response=$(curl -ks -X POST \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-H "X-API-ID: $API_ID" \ -H "X-API-ID: $API_ID" \
-H "X-API-KEY: $API_KEY" \ -H "X-API-KEY: $API_KEY" \
@@ -920,7 +913,7 @@ check_version() {
info "Checking for agent updates..." 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 if [[ $? -eq 0 ]]; then
local current_version=$(echo "$response" | grep -o '"currentVersion":"[^"]*' | cut -d'"' -f4) local current_version=$(echo "$response" | grep -o '"currentVersion":"[^"]*' | cut -d'"' -f4)
@@ -949,38 +942,129 @@ check_version() {
fi 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 script
update_agent() { update_agent() {
load_credentials load_credentials
info "Updating agent script..." 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" info "Downloading latest agent from: $download_url"
# Create backup of current script # Clean up old backups (keep only last 3)
cp "$0" "$0.backup.$(date +%Y%m%d_%H%M%S)" ls -t "$0.backup."* 2>/dev/null | tail -n +4 | xargs -r rm -f
# Download new version # Create backup of current script
if curl -ksv -o "/tmp/patchmon-agent-new.sh" "$download_url"; then 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 # Verify the downloaded script is valid
if bash -n "/tmp/patchmon-agent-new.sh" 2>/dev/null; then if bash -n "/tmp/patchmon-agent-new.sh" 2>/dev/null; then
# Replace current script # Replace current script
mv "/tmp/patchmon-agent-new.sh" "$0" mv "/tmp/patchmon-agent-new.sh" "$0"
chmod +x "$0" chmod +x "$0"
success "Agent updated successfully" success "Agent updated successfully"
info "Backup saved as: $0.backup.$(date +%Y%m%d_%H%M%S)" info "Backup saved as: $backup_file"
# Get the new version number # Get the new version number
local new_version=$(grep '^AGENT_VERSION=' "$0" | cut -d'"' -f2) local new_version=$(grep '^AGENT_VERSION=' "$0" | cut -d'"' -f2)
@@ -1000,16 +1084,13 @@ update_agent() {
else else
error "Failed to download new agent script" error "Failed to download new agent script"
fi fi
else
error "Failed to get update information"
fi
} }
# Update crontab with current policy # Update crontab with current policy
update_crontab() { update_crontab() {
load_credentials load_credentials
info "Updating crontab with current policy..." 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 if [[ $? -eq 0 ]]; then
local update_interval=$(echo "$response" | grep -o '"updateInterval":[0-9]*' | cut -d':' -f2) local update_interval=$(echo "$response" | grep -o '"updateInterval":[0-9]*' | cut -d':' -f2)
if [[ -n "$update_interval" ]]; then 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" expected_crontab="*/$update_interval * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1"
fi fi
# Get current crontab # Get current crontab (without patchmon entries)
local current_crontab=$(crontab -l 2>/dev/null | grep "patchmon-agent.sh update" | head -1) 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 # 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)" info "Crontab is already up to date (interval: $update_interval minutes)"
return 0 return 0
fi fi
info "Setting update interval to $update_interval minutes" 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 else
error "Could not determine update interval from server" error "Could not determine update interval from server"
fi fi
@@ -1147,7 +1237,7 @@ main() {
check_root check_root
setup_directories setup_directories
load_config load_config
configure_credentials "$2" "$3" configure_credentials "$2" "$3" "$4"
;; ;;
"test") "test")
check_root check_root
@@ -1178,6 +1268,11 @@ main() {
load_config load_config
check_version check_version
;; ;;
"check-agent-update")
setup_directories
load_config
check_agent_update
;;
"update-agent") "update-agent")
check_root check_root
setup_directories setup_directories
@@ -1195,22 +1290,23 @@ main() {
;; ;;
*) *)
echo "PatchMon Agent v$AGENT_VERSION - API Credential Based" 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 ""
echo "Commands:" 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 " test - Test API credentials connectivity"
echo " update - Send package update information to server" echo " update - Send package update information to server"
echo " ping - Test connectivity to server" echo " ping - Test connectivity to server"
echo " config - Show current configuration" echo " config - Show current configuration"
echo " check-version - Check for agent updates" 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-agent - Update agent to latest version"
echo " update-crontab - Update crontab with current policy" echo " update-crontab - Update crontab with current policy"
echo " diagnostics - Show detailed system diagnostics" echo " diagnostics - Show detailed system diagnostics"
echo "" echo ""
echo "Setup Process:" echo "Setup Process:"
echo " 1. Contact your PatchMon administrator to create a host entry" 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 " 3. Run: $0 test (to verify connection)"
echo " 4. Run: $0 update (to send initial package data)" echo " 4. Run: $0 update (to send initial package data)"
echo "" echo ""

View File

@@ -1,7 +1,7 @@
#!/bin/bash #!/bin/bash
# PatchMon Agent Installation Script # 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 set -e
@@ -35,10 +35,34 @@ if [[ $EUID -ne 0 ]]; then
error "This script must be run as root (use sudo)" error "This script must be run as root (use sudo)"
fi fi
# 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 # Install required dependencies
info "📦 Installing 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 if command -v apt-get >/dev/null 2>&1; then
# Debian/Ubuntu # Debian/Ubuntu
apt-get update >/dev/null 2>&1 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." warning "Could not detect package manager. Please ensure 'jq' and 'curl' are installed manually."
fi fi
# Verify jq installation # Step 1: Handle existing configuration directory
if ! command -v jq >/dev/null 2>&1; then info "📁 Setting up configuration directory..."
error "Failed to install 'jq'. Please install it manually: https://stedolan.github.io/jq/download/"
fi
success "Dependencies installed successfully!" # Check if configuration directory already exists
if [[ -d "/etc/patchmon" ]]; then
warning "⚠️ Configuration directory already exists at /etc/patchmon"
warning "⚠️ Preserving existing configuration files"
# Default server URL (will be replaced by backend with configured URL) # List existing files for user awareness
PATCHMON_URL="http://localhost:3001" info "📋 Existing files in /etc/patchmon:"
ls -la /etc/patchmon/ 2>/dev/null | grep -v "^total" | while read -r line; do
# Parse arguments echo " $line"
if [[ $# -ne 3 ]]; then done
echo "Usage: curl -ksSL {PATCHMON_URL}/api/v1/hosts/install | bash -s -- {PATCHMON_URL} {API_ID} {API_KEY}" else
echo "" info "📁 Creating new configuration directory..."
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
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 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 fi
# Get update interval policy from server # Step 2: Create credentials file
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 "🔐 Creating API credentials file..."
info "📋 Update interval: $UPDATE_INTERVAL minutes"
# 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
# Create credentials file
info "🔐 Setting up API credentials..."
cat > /etc/patchmon/credentials << EOF cat > /etc/patchmon/credentials << EOF
# PatchMon API Credentials # PatchMon API Credentials
# Generated on $(date) # Generated on $(date)
@@ -139,63 +127,141 @@ PATCHMON_URL="$PATCHMON_URL"
API_ID="$API_ID" API_ID="$API_ID"
API_KEY="$API_KEY" API_KEY="$API_KEY"
EOF EOF
chmod 600 /etc/patchmon/credentials chmod 600 /etc/patchmon/credentials
# Test the configuration # Step 3: Download the agent script using API credentials
info "🧪 Testing configuration..." 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 if /usr/local/bin/patchmon-agent.sh test; then
success "Configuration test passed!" success "✅ API credentials are valid and server is reachable"
else else
error "Configuration test failed. Please check your credentials." error "❌ Failed to validate API credentials or reach server"
fi fi
# Send initial update # Step 5: Send initial data
info "📊 Sending initial package data..." info "📊 Sending initial package data to server..."
if /usr/local/bin/patchmon-agent.sh update; then if /usr/local/bin/patchmon-agent.sh update; then
success "Initial package data sent successfully!" success "Initial package data sent successfully"
else 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 fi
# Setup crontab for automatic package status updates # Step 6: Get update interval policy from server and setup crontab
info "Setting up automatic package status update every $UPDATE_INTERVAL minutes..." 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 info "📋 Update interval: $UPDATE_INTERVAL minutes"
PATCHMON_CRON_EXISTS=$(crontab -l 2>/dev/null | grep -c "patchmon-agent.sh update" || true)
if [[ $PATCHMON_CRON_EXISTS -gt 0 ]]; then # Setup crontab (smart duplicate detection)
info " Existing PatchMon cron job found, removing old entry..." info "📅 Setting up automated updates..."
# Remove existing patchmon cron entries and preserve other entries
(crontab -l 2>/dev/null | grep -v "patchmon-agent.sh update") | crontab - # 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 fi
if [[ $UPDATE_INTERVAL -eq 60 ]]; then # Function to setup crontab without duplicates
# Hourly updates - safely append to existing crontab setup_crontab() {
(crontab -l 2>/dev/null; echo "0 * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1") | 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 else
# Custom interval updates - safely append to existing crontab # Custom interval updates
(crontab -l 2>/dev/null; echo "*/$UPDATE_INTERVAL * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1") | crontab - new_entry="*/$update_interval * * * * $patchmon_pattern >/dev/null 2>&1"
info "📋 Configuring updates every $update_interval minutes"
fi fi
success "🎉 PatchMon Agent installation complete!" # 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)"
}
setup_crontab "$UPDATE_INTERVAL"
# Installation complete
success "🎉 PatchMon Agent installation completed successfully!"
echo "" echo ""
echo "📋 Installation Summary:" echo -e "${GREEN}📋 Installation Summary:${NC}"
echo " • Dependencies installed: jq, curl" echo " • Configuration directory: /etc/patchmon"
echo " • Agent installed: /usr/local/bin/patchmon-agent.sh" echo " • Agent installed: /usr/local/bin/patchmon-agent.sh"
echo "Agent version: $AGENT_VERSION" echo " • Dependencies installed: jq, curl"
if [[ "$EXPECTED_VERSION" != "Unknown" ]]; then echo " • Crontab configured for automatic updates"
echo "Expected version: $EXPECTED_VERSION" echo " • API credentials configured and tested"
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!"
# 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", "name": "patchmon-backend",
"version": "1.2.6", "version": "1.2.7",
"description": "Backend API for Linux Patch Monitoring System", "description": "Backend API for Linux Patch Monitoring System",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"main": "src/server.js", "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") 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 { model dashboard_preferences {
id String @id id String @id

View File

@@ -37,7 +37,7 @@ function createPrismaClient() {
}, },
}, },
log: log:
process.env.NODE_ENV === "development" process.env.PRISMA_LOG_QUERIES === "true"
? ["query", "info", "warn", "error"] ? ["query", "info", "warn", "error"]
: ["warn", "error"], : ["warn", "error"],
errorFormat: "pretty", errorFormat: "pretty",
@@ -66,17 +66,21 @@ async function waitForDatabase(prisma, options = {}) {
parseInt(process.env.PM_DB_CONN_WAIT_INTERVAL, 10) || parseInt(process.env.PM_DB_CONN_WAIT_INTERVAL, 10) ||
2; 2;
if (process.env.ENABLE_LOGGING === "true") {
console.log( console.log(
`Waiting for database connection (max ${maxAttempts} attempts, ${waitInterval}s interval)...`, `Waiting for database connection (max ${maxAttempts} attempts, ${waitInterval}s interval)...`,
); );
}
for (let attempt = 1; attempt <= maxAttempts; attempt++) { for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try { try {
const isConnected = await checkDatabaseConnection(prisma); const isConnected = await checkDatabaseConnection(prisma);
if (isConnected) { if (isConnected) {
if (process.env.ENABLE_LOGGING === "true") {
console.log( console.log(
`Database connected successfully after ${attempt} attempt(s)`, `Database connected successfully after ${attempt} attempt(s)`,
); );
}
return true; return true;
} }
} catch { } catch {
@@ -84,9 +88,11 @@ async function waitForDatabase(prisma, options = {}) {
} }
if (attempt < maxAttempts) { if (attempt < maxAttempts) {
if (process.env.ENABLE_LOGGING === "true") {
console.log( console.log(
`⏳ Database not ready (attempt ${attempt}/${maxAttempts}), retrying in ${waitInterval}s...`, `⏳ Database not ready (attempt ${attempt}/${maxAttempts}), retrying in ${waitInterval}s...`,
); );
}
await new Promise((resolve) => setTimeout(resolve, waitInterval * 1000)); 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 { body, validationResult } = require("express-validator");
const { v4: uuidv4 } = require("uuid"); const { v4: uuidv4 } = require("uuid");
const crypto = require("node:crypto"); const crypto = require("node:crypto");
const path = require("node:path"); const _path = require("node:path");
const fs = require("node:fs"); const _fs = require("node:fs");
const { authenticateToken, _requireAdmin } = require("../middleware/auth"); const { authenticateToken, _requireAdmin } = require("../middleware/auth");
const { const {
requireManageHosts, requireManageHosts,
@@ -14,72 +14,48 @@ const {
const router = express.Router(); const router = express.Router();
const prisma = new PrismaClient(); 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) => { router.get("/agent/download", async (req, res) => {
try { 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 (!apiId || !apiKey) {
return res.status(401).json({ error: "API credentials required" });
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" },
});
}
} }
// Use script content from database if available, otherwise fallback to file // Validate API credentials
if (agentVersion?.script_content) { const host = await prisma.hosts.findUnique({
// Convert Windows line endings to Unix line endings where: { api_id: apiId },
const scriptContent = agentVersion.script_content });
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n"); if (!host || host.api_key !== apiKey) {
res.setHeader("Content-Type", "application/x-shellscript"); return res.status(401).json({ error: "Invalid API credentials" });
res.setHeader( }
"Content-Disposition",
`attachment; filename="patchmon-agent-${agentVersion.version}.sh"`, // Serve agent script directly from file system
); const fs = require("node:fs");
res.send(scriptContent); const path = require("node:path");
} else {
// Fallback to file system when no database version exists or script has no content const agentPath = path.join(__dirname, "../../../agents/patchmon-agent.sh");
const agentPath = path.join(
__dirname,
"../../../agents/patchmon-agent.sh",
);
if (!fs.existsSync(agentPath)) { if (!fs.existsSync(agentPath)) {
return res.status(404).json({ error: "Agent script not found" }); return res.status(404).json({ error: "Agent script not found" });
} }
// Read file and convert line endings // Read file and convert line endings
const scriptContent = fs const scriptContent = fs
.readFileSync(agentPath, "utf8") .readFileSync(agentPath, "utf8")
.replace(/\r\n/g, "\n") .replace(/\r\n/g, "\n")
.replace(/\r/g, "\n"); .replace(/\r/g, "\n");
res.setHeader("Content-Type", "application/x-shellscript"); res.setHeader("Content-Type", "application/x-shellscript");
const version = agentVersion ? `-${agentVersion.version}` : "";
res.setHeader( res.setHeader(
"Content-Disposition", "Content-Disposition",
`attachment; filename="patchmon-agent${version}.sh"`, 'attachment; filename="patchmon-agent.sh"',
); );
res.send(scriptContent); res.send(scriptContent);
}
} catch (error) { } catch (error) {
console.error("Agent download error:", error); console.error("Agent download error:", error);
res.status(500).json({ error: "Failed to download agent script" }); 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 // Version check endpoint for agents
router.get("/agent/version", async (_req, res) => { router.get("/agent/version", async (_req, res) => {
try { try {
const currentVersion = await prisma.agent_versions.findFirst({ const fs = require("node:fs");
where: { is_current: true }, const path = require("node:path");
orderBy: { created_at: "desc" },
});
if (!currentVersion) { // Read version directly from agent script file
return res.status(404).json({ error: "No current agent version found" }); 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({ res.json({
currentVersion: currentVersion.version, currentVersion: currentVersion,
downloadUrl: downloadUrl: `/api/v1/hosts/agent/download`,
currentVersion.download_url || `/api/v1/hosts/agent/download`, releaseNotes: `PatchMon Agent v${currentVersion}`,
releaseNotes: currentVersion.release_notes, minServerVersion: null,
minServerVersion: currentVersion.min_server_version,
}); });
} catch (error) { } catch (error) {
console.error("Version check error:", 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 // Agent auto-update is now handled client-side by the agent itself
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
}
const response = { const response = {
message: "Host updated successfully", message: "Host updated successfully",
@@ -571,11 +523,6 @@ router.post(
securityUpdates: securityCount, securityUpdates: securityCount,
}; };
// Add agent auto-update response if available
if (autoUpdateResponse) {
response.autoUpdate = autoUpdateResponse;
}
// Check if crontab update is needed (when update interval changes) // 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 // This is a simple check - if the host has auto-update enabled, we'll suggest crontab update
if (host.auto_update) { if (host.auto_update) {
@@ -1103,9 +1050,26 @@ router.patch(
}, },
); );
// Serve the installation script // Serve the installation script (requires API authentication)
router.get("/install", async (_req, res) => { router.get("/install", async (req, res) => {
try { 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 fs = require("node:fs");
const path = require("node:path"); 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"); script = script.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
// Get the configured server URL from settings // Get the configured server URL from settings
let serverUrl = "http://localhost:3001";
try { try {
const settings = await prisma.settings.findFirst(); const settings = await prisma.settings.findFirst();
if (settings) { if (settings?.server_url) {
// Replace the default server URL in the script with the configured one serverUrl = settings.server_url;
script = script.replace(
/PATCHMON_URL="[^"]*"/g,
`PATCHMON_URL="${settings.server_url}"`,
);
} }
} catch (settingsError) { } catch (settingsError) {
console.warn( 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-Type", "text/plain");
res.setHeader( res.setHeader(
"Content-Disposition", "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( router.get(
"/agent/versions", "/agent/info",
authenticateToken, authenticateToken,
requireManageSettings, requireManageSettings,
async (_req, res) => { async (_req, res) => {
try { try {
const versions = await prisma.agent_versions.findMany({ const fs = require("node:fs").promises;
orderBy: { created_at: "desc" }, const path = require("node:path");
});
res.json(versions); const agentPath = path.join(
} catch (error) { __dirname,
console.error("Get agent versions error:", error); "../../../agents/patchmon-agent.sh",
res.status(500).json({ error: "Failed to get agent versions" });
}
},
); );
// 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 { try {
const errors = validationResult(req); const stats = await fs.stat(agentPath);
if (!errors.isEmpty()) { const content = await fs.readFile(agentPath, "utf8");
return res.status(400).json({ errors: errors.array() });
}
const { // Extract version from agent script (look for AGENT_VERSION= line)
version, const versionMatch = content.match(/^AGENT_VERSION="([^"]+)"/m);
releaseNotes, const version = versionMatch ? versionMatch[1] : "unknown";
downloadUrl,
minServerVersion,
scriptContent,
isDefault,
} = req.body;
// 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(),
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);
} catch (error) {
console.error("Create agent version error:", error);
res.status(500).json({ error: "Failed to create agent version" });
}
},
);
// Set current agent version (admin only)
router.patch(
"/agent/versions/:versionId/current",
authenticateToken,
requireManageSettings,
async (req, res) => {
try {
const { versionId } = req.params;
// First, unset all current versions
await prisma.agent_versions.updateMany({
where: { is_current: true },
data: { is_current: false, updated_at: new Date() },
});
// 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) {
return res.status(400).json({
error: "Invalid agent version ID format",
details: "The provided ID does not match expected format",
});
}
const agentVersion = await prisma.agent_versions.findUnique({
where: { id: versionId },
});
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",
});
}
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",
});
}
await prisma.agent_versions.delete({
where: { id: versionId },
});
res.json({ res.json({
message: "Agent version deleted successfully", exists: true,
deletedVersion: agentVersion.version, version,
lastModified: stats.mtime,
size: stats.size,
sizeFormatted: `${(stats.size / 1024).toFixed(1)} KB`,
}); });
} catch (error) { } catch (error) {
console.error("Delete agent version error:", error); if (error.code === "ENOENT") {
res.status(500).json({ res.json({
error: "Failed to delete agent version", exists: false,
details: error.message, version: null,
lastModified: null,
size: 0,
sizeFormatted: "0 KB",
}); });
} else {
throw error;
}
}
} catch (error) {
console.error("Get agent info error:", error);
res.status(500).json({ error: "Failed to get agent information" });
} }
}, },
); );
// Update agent file (admin only)
router.post(
"/agent/upload",
authenticateToken,
requireManageSettings,
async (req, res) => {
try {
const { scriptContent } = req.body;
if (!scriptContent || typeof scriptContent !== "string") {
return res.status(400).json({ error: "Script content is required" });
}
// Basic validation - check if it looks like a shell script
if (!scriptContent.trim().startsWith("#!/")) {
return res.status(400).json({
error: "Invalid script format - must start with shebang (#!/...)",
});
}
const fs = require("node:fs").promises;
const path = require("node:path");
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);
}
}
// Write new agent script
await fs.writeFile(agentPath, scriptContent, { mode: 0o755 });
// 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 script updated successfully",
version,
lastModified: stats.mtime,
size: stats.size,
sizeFormatted: `${(stats.size / 1024).toFixed(1)} KB`,
});
} catch (error) {
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) // Update host friendly name (admin only)
router.patch( router.patch(
"/:hostId/friendly-name", "/:hostId/friendly-name",

View File

@@ -109,7 +109,9 @@ async function triggerCrontabUpdates() {
router.get("/", authenticateToken, requireManageSettings, async (_req, res) => { router.get("/", authenticateToken, requireManageSettings, async (_req, res) => {
try { try {
const settings = await getSettings(); const settings = await getSettings();
if (process.env.ENABLE_LOGGING === "true") {
console.log("Returning settings:", settings); console.log("Returning settings:", settings);
}
res.json(settings); res.json(settings);
} catch (error) { } catch (error) {
console.error("Settings fetch error:", 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) // Get update interval policy for agents (requires API authentication)
router.get("/update-interval", async (_req, res) => { router.get("/update-interval", async (req, res) => {
try { 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(); const settings = await getSettings();
res.json({ res.json({
updateInterval: settings.update_interval, updateInterval: settings.update_interval,

View File

@@ -14,7 +14,7 @@ const router = express.Router();
router.get("/current", authenticateToken, async (_req, res) => { router.get("/current", authenticateToken, async (_req, res) => {
try { try {
// Read version from package.json dynamically // Read version from package.json dynamically
let currentVersion = "1.2.6"; // fallback let currentVersion = "1.2.7"; // fallback
try { try {
const packageJson = require("../../package.json"); const packageJson = require("../../package.json");
@@ -174,7 +174,7 @@ router.get(
return res.status(400).json({ error: "Settings not found" }); 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 latestVersion = settings.latest_version || currentVersion;
const isUpdateAvailable = settings.update_available || false; const isUpdateAvailable = settings.update_available || false;
const lastUpdateCheck = settings.last_update_check || null; 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 // Initialize Prisma client with optimized connection pooling for multiple instances
const prisma = createPrismaClient(); 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 // Function to check and create default role permissions on startup
async function checkAndCreateRolePermissions() { async function checkAndCreateRolePermissions() {
console.log("🔐 Starting role permissions auto-creation check..."); console.log("🔐 Starting role permissions auto-creation check...");
@@ -610,8 +416,6 @@ process.on("SIGTERM", async () => {
// Initialize dashboard preferences for all users // Initialize dashboard preferences for all users
async function initializeDashboardPreferences() { async function initializeDashboardPreferences() {
try { try {
console.log("🔧 Initializing dashboard preferences for all users...");
// Get all users // Get all users
const users = await prisma.users.findMany({ const users = await prisma.users.findMany({
select: { select: {
@@ -628,12 +432,9 @@ async function initializeDashboardPreferences() {
}); });
if (users.length === 0) { if (users.length === 0) {
console.log(" No users found in database");
return; return;
} }
console.log(`📊 Found ${users.length} users to initialize`);
let initializedCount = 0; let initializedCount = 0;
let updatedCount = 0; let updatedCount = 0;
@@ -648,10 +449,6 @@ async function initializeDashboardPreferences() {
if (!hasPreferences) { if (!hasPreferences) {
// User has no preferences - create them // User has no preferences - create them
console.log(
`⚙️ Creating preferences for ${user.username} (${user.role})`,
);
const preferencesData = expectedPreferences.map((pref) => ({ const preferencesData = expectedPreferences.map((pref) => ({
id: require("uuid").v4(), id: require("uuid").v4(),
user_id: user.id, user_id: user.id,
@@ -667,18 +464,11 @@ async function initializeDashboardPreferences() {
}); });
initializedCount++; initializedCount++;
console.log(
` ✅ Created ${expectedCardCount} cards based on permissions`,
);
} else { } else {
// User already has preferences - check if they need updating // User already has preferences - check if they need updating
const currentCardCount = user.dashboard_preferences.length; const currentCardCount = user.dashboard_preferences.length;
if (currentCardCount !== expectedCardCount) { if (currentCardCount !== expectedCardCount) {
console.log(
`🔄 Updating preferences for ${user.username} (${user.role}) - ${currentCardCount}${expectedCardCount} cards`,
);
// Delete existing preferences // Delete existing preferences
await prisma.dashboard_preferences.deleteMany({ await prisma.dashboard_preferences.deleteMany({
where: { user_id: user.id }, where: { user_id: user.id },
@@ -700,29 +490,16 @@ async function initializeDashboardPreferences() {
}); });
updatedCount++; 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:`); // Only show summary if there were changes
console.log(` - New users initialized: ${initializedCount}`); if (initializedCount > 0 || updatedCount > 0) {
console.log(` - Existing users updated: ${updatedCount}`);
console.log( console.log(
` - Users with correct preferences: ${users.length - initializedCount - updatedCount}`, `✅ Dashboard preferences: ${initializedCount} initialized, ${updatedCount} updated`,
); );
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`);
} catch (error) { } catch (error) {
console.error("❌ Error initializing dashboard preferences:", error); console.error("❌ Error initializing dashboard preferences:", error);
throw error; throw error;
@@ -889,9 +666,6 @@ async function startServer() {
throw initError; // Fail startup if settings can't be initialised 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 // Check and create default role permissions on startup
await checkAndCreateRolePermissions(); await checkAndCreateRolePermissions();

View File

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

View File

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

View File

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

View File

@@ -1083,13 +1083,13 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
One-Line Installation One-Line Installation
</h4> </h4>
<p className="text-sm text-primary-700 dark:text-primary-300 mb-3"> <p className="text-sm text-primary-700 dark:text-primary-300 mb-3">
Copy and run this command on the target host to automatically Copy and run this command on the target host to securely install
install and configure the PatchMon agent: and configure the PatchMon agent:
</p> </p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
type="text" 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 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" 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" type="button"
onClick={() => onClick={() =>
copyToClipboard( 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" className="btn-primary flex items-center gap-1"
@@ -1118,21 +1118,19 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
<div className="space-y-3"> <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"> <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"> <h5 className="text-sm font-medium text-secondary-900 dark:text-white mb-2">
1. Download Agent Script 1. Create Configuration Directory
</h5> </h5>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
type="text" type="text"
value={`curl -o /tmp/patchmon-agent.sh ${serverUrl}/api/v1/hosts/agent/download`} value="sudo mkdir -p /etc/patchmon"
readOnly 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" 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 <button
type="button" type="button"
onClick={() => onClick={() =>
copyToClipboard( copyToClipboard("sudo mkdir -p /etc/patchmon")
`curl -o /tmp/patchmon-agent.sh ${serverUrl}/api/v1/hosts/agent/download`,
)
} }
className="btn-secondary flex items-center gap-1" 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"> <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"> <h5 className="text-sm font-medium text-secondary-900 dark:text-white mb-2">
2. Install Agent 2. Download and Install Agent Script
</h5> </h5>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
type="text" 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 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" 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" type="button"
onClick={() => onClick={() =>
copyToClipboard( 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" className="btn-secondary flex items-center gap-1"
@@ -1175,7 +1173,7 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
type="text" 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 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" 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" type="button"
onClick={() => onClick={() =>
copyToClipboard( 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" className="btn-secondary flex items-center gap-1"
@@ -1253,7 +1251,7 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
type="text" 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 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" 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" type="button"
onClick={() => onClick={() =>
copyToClipboard( 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" className="btn-secondary flex items-center gap-1"

View File

@@ -10,8 +10,6 @@ import {
Server, Server,
Settings as SettingsIcon, Settings as SettingsIcon,
Shield, Shield,
Star,
Trash2,
X, X,
} from "lucide-react"; } from "lucide-react";
@@ -19,23 +17,15 @@ import { useEffect, useId, useState } from "react";
import UpgradeNotificationIcon from "../components/UpgradeNotificationIcon"; import UpgradeNotificationIcon from "../components/UpgradeNotificationIcon";
import { useUpdateNotification } from "../contexts/UpdateNotificationContext"; import { useUpdateNotification } from "../contexts/UpdateNotificationContext";
import { import {
agentVersionAPI, agentFileAPI,
permissionsAPI, permissionsAPI,
settingsAPI, settingsAPI,
versionAPI, versionAPI,
} from "../utils/api"; } from "../utils/api";
const Settings = () => { const Settings = () => {
const repoPublicId = useId(); const scriptFileId = useId();
const repoPrivateId = useId(); const scriptContentId = 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 [formData, setFormData] = useState({ const [formData, setFormData] = useState({
serverProtocol: "http", serverProtocol: "http",
serverHost: "localhost", serverHost: "localhost",
@@ -70,8 +60,15 @@ const Settings = () => {
}, },
]; ];
// Agent version management state // Agent management state
const [showAgentVersionModal, setShowAgentVersionModal] = useState(false); const [_agentInfo, _setAgentInfo] = useState({
version: null,
lastModified: null,
size: null,
loading: true,
error: null,
});
const [showUploadModal, setShowUploadModal] = useState(false);
// Version checking state // Version checking state
const [versionInfo, setVersionInfo] = useState({ const [versionInfo, setVersionInfo] = useState({
@@ -157,17 +154,26 @@ const Settings = () => {
}, },
}); });
// Agent version queries and mutations // Agent file queries and mutations
const { const {
data: agentVersions, data: agentFileInfo,
isLoading: agentVersionsLoading, isLoading: agentFileLoading,
error: agentVersionsError, error: agentFileError,
refetch: refetchAgentFile,
} = useQuery({ } = useQuery({
queryKey: ["agentVersions"], queryKey: ["agentFile"],
queryFn: () => { queryFn: () => agentFileAPI.getInfo().then((res) => res.data),
return agentVersionAPI.list().then((res) => {
return 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(); 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 // Version checking functions
const checkForUpdates = async () => { const checkForUpdates = async () => {
setVersionInfo((prev) => ({ ...prev, checking: true, error: null })); setVersionInfo((prev) => ({ ...prev, checking: true, error: null }));
@@ -813,180 +762,164 @@ const Settings = () => {
<div className="flex items-center mb-2"> <div className="flex items-center mb-2">
<SettingsIcon className="h-6 w-6 text-primary-600 mr-3" /> <SettingsIcon className="h-6 w-6 text-primary-600 mr-3" />
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white"> <h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
Agent Version Management Agent File Management
</h2> </h2>
</div> </div>
<p className="text-sm text-secondary-500 dark:text-secondary-300"> <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> </p>
</div> </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>
{/* 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 ? (
<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 ? (
<div className="text-center py-8">
<p className="text-red-600 dark:text-red-400">
Error loading agent versions: {agentVersionsError.message}
</p>
</div>
) : !agentVersions || agentVersions.length === 0 ? (
<div className="text-center py-8">
<p className="text-secondary-500 dark:text-secondary-400">
No agent versions found
</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"> <div className="flex items-center gap-2">
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
const downloadUrl = `/api/v1/hosts/agent/download?version=${version.version}`; const url = "/api/v1/hosts/agent/download";
window.open(downloadUrl, "_blank"); 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 text-xs flex items-center gap-1" className="btn-outline flex items-center gap-2"
> >
<Download className="h-3 w-3" /> <Download className="h-4 w-4" />
Download Download
</button> </button>
<button <button
type="button" type="button"
onClick={() => onClick={() => setShowUploadModal(true)}
setCurrentAgentVersionMutation.mutate(version.id) className="btn-primary flex items-center gap-2"
}
disabled={
version.is_current ||
setCurrentAgentVersionMutation.isPending
}
className="btn-outline text-xs flex items-center gap-1"
> >
<CheckCircle className="h-3 w-3" /> <Plus className="h-4 w-4" />
Set Current Replace Script
</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> </button>
</div> </div>
</div> </div>
</div>
))}
{agentVersions?.length === 0 && ( {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>
) : agentFileError ? (
<div className="text-center py-8">
<p className="text-red-600 dark:text-red-400">
Error loading agent file: {agentFileError.message}
</p>
</div>
) : !agentFileInfo?.exists ? (
<div className="text-center py-8"> <div className="text-center py-8">
<Code className="h-12 w-12 text-secondary-400 mx-auto mb-4" /> <Code className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<p className="text-secondary-500 dark:text-secondary-300"> <p className="text-secondary-500 dark:text-secondary-300">
No agent versions found No agent script found
</p> </p>
<p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2"> <p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2">
Add your first agent version to get started Upload an agent script to get started
</p> </p>
</div> </div>
)} ) : (
<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>
{/* 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>
)} )}
</div> </div>
@@ -1330,52 +1263,42 @@ const Settings = () => {
</div> </div>
</div> </div>
{/* Agent Version Modal */} {/* Agent Upload Modal */}
{showAgentVersionModal && ( {showUploadModal && (
<AgentVersionModal <AgentUploadModal
isOpen={showAgentVersionModal} isOpen={showUploadModal}
onClose={() => { onClose={() => setShowUploadModal(false)}
setShowAgentVersionModal(false); onSubmit={uploadAgentMutation.mutate}
setAgentVersionForm({ isLoading={uploadAgentMutation.isPending}
version: "", error={uploadAgentMutation.error}
releaseNotes: "",
scriptContent: "",
isDefault: false,
});
}}
onSubmit={createAgentVersionMutation.mutate}
isLoading={createAgentVersionMutation.isPending}
/> />
)} )}
</div> </div>
); );
}; };
// Agent Version Modal Component // Agent Upload Modal Component
const AgentVersionModal = ({ isOpen, onClose, onSubmit, isLoading }) => { const AgentUploadModal = ({ isOpen, onClose, onSubmit, isLoading, error }) => {
const [formData, setFormData] = useState({ const [scriptContent, setScriptContent] = useState("");
version: "", const [uploadError, setUploadError] = useState("");
releaseNotes: "",
scriptContent: "",
isDefault: false,
});
const [errors, setErrors] = useState({});
const handleSubmit = (e) => { const handleSubmit = (e) => {
e.preventDefault(); e.preventDefault();
setUploadError("");
// Basic validation if (!scriptContent.trim()) {
const newErrors = {}; setUploadError("Script content is required");
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);
return; 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) => { const handleFileUpload = (e) => {
@@ -1383,10 +1306,8 @@ const AgentVersionModal = ({ isOpen, onClose, onSubmit, isLoading }) => {
if (file) { if (file) {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (event) => { reader.onload = (event) => {
setFormData((prev) => ({ setScriptContent(event.target.result);
...prev, setUploadError("");
scriptContent: event.target.result,
}));
}; };
reader.readAsText(file); reader.readAsText(file);
} }
@@ -1396,11 +1317,11 @@ const AgentVersionModal = ({ isOpen, onClose, onSubmit, isLoading }) => {
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <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="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white"> <h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Add Agent Version Replace Agent Script
</h3> </h3>
<button <button
type="button" type="button"
@@ -1416,112 +1337,69 @@ const AgentVersionModal = ({ isOpen, onClose, onSubmit, isLoading }) => {
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label <label
htmlFor={versionId} htmlFor={scriptFileId}
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"
> >
Version * Upload Script File
</label> </label>
<input <input
id={versionId} id={scriptFileId}
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..."
/>
</div>
<div>
<label
htmlFor={scriptContentId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Script Content *
</label>
<div className="space-y-2">
<input
type="file" type="file"
accept=".sh" accept=".sh"
onChange={handleFileUpload} 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" 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 <p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
id={scriptContentId} Select a .sh file to upload, or paste the script content below
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> </p>
)}
</div>
</div> </div>
<div className="flex items-center"> <div>
<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 <label
htmlFor={isDefaultId} htmlFor={scriptContentId}
className="ml-2 block text-sm text-secondary-700 dark:text-secondary-200" className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
> >
Set as default version for new installations Script Content *
</label> </label>
<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>
{(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>
</div> </div>
@@ -1529,8 +1407,12 @@ const AgentVersionModal = ({ isOpen, onClose, onSubmit, isLoading }) => {
<button type="button" onClick={onClose} className="btn-outline"> <button type="button" onClick={onClose} className="btn-outline">
Cancel Cancel
</button> </button>
<button type="submit" disabled={isLoading} className="btn-primary"> <button
{isLoading ? "Creating..." : "Create Version"} type="submit"
disabled={isLoading || !scriptContent.trim()}
className="btn-primary"
>
{isLoading ? "Uploading..." : "Replace Script"}
</button> </button>
</div> </div>
</form> </form>

View File

@@ -114,18 +114,11 @@ export const settingsAPI = {
getServerUrl: () => api.get("/settings/server-url"), getServerUrl: () => api.get("/settings/server-url"),
}; };
// Agent Version API // Agent File Management API
export const agentVersionAPI = { export const agentFileAPI = {
list: () => api.get("/hosts/agent/versions"), getInfo: () => api.get("/hosts/agent/info"),
create: (data) => api.post("/hosts/agent/versions", data), upload: (scriptContent) => api.post("/hosts/agent/upload", { scriptContent }),
update: (id, data) => api.put(`/hosts/agent/versions/${id}`, data), download: () => api.get("/hosts/agent/download", { responseType: "blob" }),
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",
}),
}; };
// Repository API // Repository API

8
package-lock.json generated
View File

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

View File

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

101
setup.sh
View File

@@ -34,7 +34,7 @@ BLUE='\033[0;34m'
NC='\033[0m' # No Color NC='\033[0m' # No Color
# Global variables # 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" DEFAULT_GITHUB_REPO="https://github.com/9technologygroup/patchmon.net.git"
FQDN="" FQDN=""
CUSTOM_FQDN="" CUSTOM_FQDN=""
@@ -819,7 +819,7 @@ EOF
cat > frontend/.env << EOF cat > frontend/.env << EOF
VITE_API_URL=$SERVER_PROTOCOL_SEL://$FQDN/api/v1 VITE_API_URL=$SERVER_PROTOCOL_SEL://$FQDN/api/v1
VITE_APP_NAME=PatchMon VITE_APP_NAME=PatchMon
VITE_APP_VERSION=1.2.6 VITE_APP_VERSION=1.2.7
EOF EOF
print_status "Environment files created" print_status "Environment files created"
@@ -1191,7 +1191,7 @@ create_agent_version() {
# Priority 2: Use fallback version if not found # Priority 2: Use fallback version if not found
if [ "$current_version" = "N/A" ] || [ -z "$current_version" ]; then 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" print_warning "Could not determine version, using fallback: $current_version"
fi fi
@@ -1208,100 +1208,7 @@ create_agent_version() {
if [ -f "$APP_DIR/agents/patchmon-agent.sh" ]; then if [ -f "$APP_DIR/agents/patchmon-agent.sh" ]; then
cp "$APP_DIR/agents/patchmon-agent.sh" "$APP_DIR/backend/" cp "$APP_DIR/agents/patchmon-agent.sh" "$APP_DIR/backend/"
# Create agent version using Node.js script print_status "Agent version management removed - using file-based approach"
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
} }
# Create deployment summary # Create deployment summary