mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-11-03 05:23:45 +00:00
Compare commits
15 Commits
fcd1b52e0e
...
post1-3-0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de449c547f | ||
|
|
a8bd09be89 | ||
|
|
3ae8422487 | ||
|
|
c98203a997 | ||
|
|
37c8f5fa76 | ||
|
|
50e546ee7e | ||
|
|
2174abf395 | ||
|
|
1350fd4e47 | ||
|
|
6b9a42fb0b | ||
|
|
3ee6f9aaa0 | ||
|
|
8a5d61a7c1 | ||
|
|
df502c676f | ||
|
|
54cea6b20b | ||
|
|
af9b0d5d76 | ||
|
|
7b8c29860c |
179
agents/legacy-patchmon-agent.sh → agents/patchmon-agent-legacy1-2-8.sh
Executable file → Normal file
179
agents/legacy-patchmon-agent.sh → agents/patchmon-agent-legacy1-2-8.sh
Executable file → Normal file
@@ -1,12 +1,12 @@
|
||||
#!/bin/bash
|
||||
|
||||
# PatchMon Agent Script v1.2.9
|
||||
# PatchMon Agent Script v1.2.8
|
||||
# This script sends package update information to the PatchMon server using API credentials
|
||||
|
||||
# Configuration
|
||||
PATCHMON_SERVER="${PATCHMON_SERVER:-http://localhost:3001}"
|
||||
API_VERSION="v1"
|
||||
AGENT_VERSION="1.2.9"
|
||||
AGENT_VERSION="1.2.8"
|
||||
CONFIG_FILE="/etc/patchmon/agent.conf"
|
||||
CREDENTIALS_FILE="/etc/patchmon/credentials"
|
||||
LOG_FILE="/var/log/patchmon-agent.log"
|
||||
@@ -38,21 +38,21 @@ error() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Info logging (cleaner output - only stderr, no duplicate logging)
|
||||
# Info logging (cleaner output - only stdout, no duplicate logging)
|
||||
info() {
|
||||
echo -e "${BLUE}ℹ️ $1${NC}" >&2
|
||||
echo -e "${BLUE}ℹ️ $1${NC}"
|
||||
log "INFO: $1"
|
||||
}
|
||||
|
||||
# Success logging (cleaner output - only stderr, no duplicate logging)
|
||||
# Success logging (cleaner output - only stdout, no duplicate logging)
|
||||
success() {
|
||||
echo -e "${GREEN}✅ $1${NC}" >&2
|
||||
echo -e "${GREEN}✅ $1${NC}"
|
||||
log "SUCCESS: $1"
|
||||
}
|
||||
|
||||
# Warning logging (cleaner output - only stderr, no duplicate logging)
|
||||
# Warning logging (cleaner output - only stdout, no duplicate logging)
|
||||
warning() {
|
||||
echo -e "${YELLOW}⚠️ $1${NC}" >&2
|
||||
echo -e "${YELLOW}⚠️ $1${NC}"
|
||||
log "WARNING: $1"
|
||||
}
|
||||
|
||||
@@ -709,135 +709,6 @@ get_package_info() {
|
||||
echo "$packages_json"
|
||||
}
|
||||
|
||||
# Check and handle APT locks
|
||||
handle_apt_locks() {
|
||||
local interactive=${1:-false} # First parameter indicates if running interactively
|
||||
|
||||
local lock_files=(
|
||||
"/var/lib/dpkg/lock"
|
||||
"/var/lib/dpkg/lock-frontend"
|
||||
"/var/lib/apt/lists/lock"
|
||||
"/var/cache/apt/archives/lock"
|
||||
)
|
||||
|
||||
local processes_found=false
|
||||
local hung_processes=()
|
||||
|
||||
# Check for running APT processes
|
||||
if pgrep -x "apt-get|apt|aptitude|dpkg|unattended-upgr" > /dev/null 2>&1; then
|
||||
processes_found=true
|
||||
info "Found running package management processes:"
|
||||
echo "" >&2
|
||||
|
||||
# Get process info with ACTUAL elapsed time (not CPU time)
|
||||
# Using ps -eo format to get real elapsed time
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
|
||||
local pid=$(echo "$line" | awk '{print $1}')
|
||||
local elapsed=$(echo "$line" | awk '{print $2}')
|
||||
local cmd=$(echo "$line" | awk '{for(i=3;i<=NF;i++) printf "%s ", $i; print ""}')
|
||||
|
||||
# Display process info
|
||||
echo " PID $pid: $cmd (running for $elapsed)" >&2
|
||||
|
||||
# Parse elapsed time and convert to seconds
|
||||
# Format can be: MM:SS, HH:MM:SS, DD-HH:MM:SS, or just SS
|
||||
# Use 10# prefix to force base-10 (avoid octal interpretation of leading zeros)
|
||||
local runtime_seconds=0
|
||||
if [[ "$elapsed" =~ ^([0-9]+)-([0-9]+):([0-9]+):([0-9]+)$ ]]; then
|
||||
# Format: DD-HH:MM:SS
|
||||
runtime_seconds=$(( 10#${BASH_REMATCH[1]} * 86400 + 10#${BASH_REMATCH[2]} * 3600 + 10#${BASH_REMATCH[3]} * 60 + 10#${BASH_REMATCH[4]} ))
|
||||
elif [[ "$elapsed" =~ ^([0-9]+):([0-9]+):([0-9]+)$ ]]; then
|
||||
# Format: HH:MM:SS
|
||||
runtime_seconds=$(( 10#${BASH_REMATCH[1]} * 3600 + 10#${BASH_REMATCH[2]} * 60 + 10#${BASH_REMATCH[3]} ))
|
||||
elif [[ "$elapsed" =~ ^([0-9]+):([0-9]+)$ ]]; then
|
||||
# Format: MM:SS
|
||||
runtime_seconds=$(( 10#${BASH_REMATCH[1]} * 60 + 10#${BASH_REMATCH[2]} ))
|
||||
elif [[ "$elapsed" =~ ^([0-9]+)$ ]]; then
|
||||
# Format: just seconds
|
||||
runtime_seconds=$((10#${BASH_REMATCH[1]}))
|
||||
fi
|
||||
|
||||
# Consider process hung if running for more than 5 minutes
|
||||
if [[ $runtime_seconds -gt 300 ]]; then
|
||||
hung_processes+=("$pid:$elapsed:$cmd")
|
||||
fi
|
||||
done < <(ps -eo pid,etime,cmd | grep -E "apt-get|apt[^-]|aptitude|dpkg|unattended-upgr" | grep -v grep | grep -v "ps -eo")
|
||||
|
||||
echo "" >&2
|
||||
|
||||
info "Detected ${#hung_processes[@]} hung process(es), interactive=$interactive"
|
||||
|
||||
# If hung processes found and running interactively, offer to kill them
|
||||
if [[ ${#hung_processes[@]} -gt 0 && "$interactive" == "true" ]]; then
|
||||
warning "Found ${#hung_processes[@]} potentially hung process(es) (running > 5 minutes)"
|
||||
echo "" >&2
|
||||
|
||||
for process_info in "${hung_processes[@]}"; do
|
||||
IFS=':' read -r pid elapsed cmd <<< "$process_info"
|
||||
echo " PID $pid: $cmd (hung for $elapsed)" >&2
|
||||
done
|
||||
|
||||
echo "" >&2
|
||||
read -p "$(echo -e "${YELLOW}⚠️ Do you want to kill these processes? [y/N]:${NC} ")" -n 1 -r >&2
|
||||
echo "" >&2
|
||||
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
for process_info in "${hung_processes[@]}"; do
|
||||
IFS=':' read -r pid elapsed cmd <<< "$process_info"
|
||||
info "Killing process $pid..."
|
||||
if kill "$pid" 2>/dev/null; then
|
||||
success "Killed process $pid"
|
||||
sleep 1
|
||||
# Check if process is still running
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
warning "Process $pid still running, using SIGKILL..."
|
||||
kill -9 "$pid" 2>/dev/null
|
||||
success "Force killed process $pid"
|
||||
fi
|
||||
else
|
||||
warning "Could not kill process $pid (may require sudo)"
|
||||
fi
|
||||
done
|
||||
|
||||
# Wait a moment for locks to clear
|
||||
sleep 2
|
||||
else
|
||||
info "Skipping process termination"
|
||||
fi
|
||||
elif [[ ${#hung_processes[@]} -gt 0 ]]; then
|
||||
warning "Found ${#hung_processes[@]} potentially hung process(es) (running > 5 minutes)"
|
||||
info "Run this command with sudo and interactively to kill hung processes"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check for stale lock files (files that exist but no process is holding them)
|
||||
for lock_file in "${lock_files[@]}"; do
|
||||
if [[ -f "$lock_file" ]]; then
|
||||
# Try to get the PID from the lock file if it exists
|
||||
if lsof "$lock_file" > /dev/null 2>&1; then
|
||||
info "Lock file $lock_file is held by an active process"
|
||||
else
|
||||
warning "Found stale lock file: $lock_file"
|
||||
info "Attempting to remove stale lock..."
|
||||
if rm -f "$lock_file" 2>/dev/null; then
|
||||
success "Removed stale lock: $lock_file"
|
||||
else
|
||||
warning "Could not remove lock (insufficient permissions): $lock_file"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# If processes were found, return failure so caller can wait
|
||||
if [[ "$processes_found" == true ]]; then
|
||||
return 1
|
||||
else
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
|
||||
# Get package info for APT-based systems
|
||||
get_apt_packages() {
|
||||
local -n packages_ref=$1
|
||||
@@ -854,25 +725,10 @@ get_apt_packages() {
|
||||
else
|
||||
retry_count=$((retry_count + 1))
|
||||
if [[ $retry_count -lt $max_retries ]]; then
|
||||
warning "APT lock detected (attempt $retry_count/$max_retries)"
|
||||
|
||||
# On first retry, try to handle locks
|
||||
if [[ $retry_count -eq 1 ]]; then
|
||||
info "Checking for stale APT locks..."
|
||||
# Check if running interactively (stdin is a terminal OR stdout is a terminal)
|
||||
local is_interactive=false
|
||||
if [[ -t 0 ]] || [[ -t 1 ]]; then
|
||||
is_interactive=true
|
||||
fi
|
||||
info "Interactive mode: $is_interactive"
|
||||
handle_apt_locks "$is_interactive"
|
||||
fi
|
||||
|
||||
info "Waiting ${retry_delay} seconds before retry..."
|
||||
warning "APT lock detected, retrying in ${retry_delay} seconds... (attempt $retry_count/$max_retries)"
|
||||
sleep $retry_delay
|
||||
else
|
||||
warning "APT lock persists after $max_retries attempts"
|
||||
warning "Continuing without updating package lists (will use cached data)"
|
||||
warning "APT lock persists after $max_retries attempts, continuing without update..."
|
||||
fi
|
||||
fi
|
||||
done
|
||||
@@ -1708,21 +1564,9 @@ main() {
|
||||
"diagnostics")
|
||||
show_diagnostics
|
||||
;;
|
||||
"clear-locks"|"unlock")
|
||||
check_root
|
||||
info "Checking APT locks and hung processes..."
|
||||
echo ""
|
||||
handle_apt_locks true
|
||||
echo ""
|
||||
if [[ $? -eq 0 ]]; then
|
||||
success "No APT locks or processes blocking package management"
|
||||
else
|
||||
info "APT processes are still running - they may be legitimate operations"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "PatchMon Agent v$AGENT_VERSION - API Credential Based"
|
||||
echo "Usage: $0 {configure|test|update|ping|config|check-version|check-agent-update|update-agent|update-crontab|clear-locks|diagnostics}"
|
||||
echo "Usage: $0 {configure|test|update|ping|config|check-version|check-agent-update|update-agent|update-crontab|diagnostics}"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " configure <API_ID> <API_KEY> [SERVER_URL] - Configure API credentials for this host"
|
||||
@@ -1734,7 +1578,6 @@ main() {
|
||||
echo " check-agent-update - Check for agent updates using timestamp comparison"
|
||||
echo " update-agent - Update agent to latest version"
|
||||
echo " update-crontab - Update crontab with current policy"
|
||||
echo " clear-locks - Check and clear APT locks (interactive)"
|
||||
echo " diagnostics - Show detailed system diagnostics"
|
||||
echo ""
|
||||
echo "Setup Process:"
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,23 +1,29 @@
|
||||
# Database Configuration
|
||||
DATABASE_URL="postgresql://patchmon_user:p@tchm0n_p@55@localhost:5432/patchmon_db"
|
||||
DATABASE_URL="postgresql://patchmon_user:your-password-here@localhost:5432/patchmon_db"
|
||||
PM_DB_CONN_MAX_ATTEMPTS=30
|
||||
PM_DB_CONN_WAIT_INTERVAL=2
|
||||
|
||||
# Redis Configuration
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_USER=your-redis-username-here
|
||||
REDIS_PASSWORD=your-redis-password-here
|
||||
REDIS_DB=0
|
||||
# JWT Configuration
|
||||
JWT_SECRET=your-secure-random-secret-key-change-this-in-production
|
||||
JWT_EXPIRES_IN=1h
|
||||
JWT_REFRESH_EXPIRES_IN=7d
|
||||
|
||||
# Server Configuration
|
||||
PORT=3001
|
||||
NODE_ENV=development
|
||||
NODE_ENV=production
|
||||
|
||||
# API Configuration
|
||||
API_VERSION=v1
|
||||
|
||||
# CORS Configuration
|
||||
CORS_ORIGIN=http://localhost:3000
|
||||
|
||||
# Session Configuration
|
||||
SESSION_INACTIVITY_TIMEOUT_MINUTES=30
|
||||
|
||||
# User Configuration
|
||||
DEFAULT_USER_ROLE=user
|
||||
|
||||
# Rate Limiting (times in milliseconds)
|
||||
RATE_LIMIT_WINDOW_MS=900000
|
||||
RATE_LIMIT_MAX=5000
|
||||
@@ -26,20 +32,18 @@ AUTH_RATE_LIMIT_MAX=500
|
||||
AGENT_RATE_LIMIT_WINDOW_MS=60000
|
||||
AGENT_RATE_LIMIT_MAX=1000
|
||||
|
||||
# Redis Configuration
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_USER=your-redis-username-here
|
||||
REDIS_PASSWORD=your-redis-password-here
|
||||
REDIS_DB=0
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=info
|
||||
ENABLE_LOGGING=true
|
||||
|
||||
# User Registration
|
||||
DEFAULT_USER_ROLE=user
|
||||
|
||||
# JWT Configuration
|
||||
JWT_SECRET=your-secure-random-secret-key-change-this-in-production
|
||||
JWT_EXPIRES_IN=1h
|
||||
JWT_REFRESH_EXPIRES_IN=7d
|
||||
SESSION_INACTIVITY_TIMEOUT_MINUTES=30
|
||||
|
||||
# TFA Configuration
|
||||
# TFA Configuration (optional - used if TFA is enabled)
|
||||
TFA_REMEMBER_ME_EXPIRES_IN=30d
|
||||
TFA_MAX_REMEMBER_SESSIONS=5
|
||||
TFA_SUSPICIOUS_ACTIVITY_THRESHOLD=3
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"@bull-board/api": "^6.13.1",
|
||||
"@bull-board/express": "^6.13.1",
|
||||
"@prisma/client": "^6.1.0",
|
||||
"axios": "^1.7.9",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bullmq": "^5.61.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
|
||||
419
backend/src/routes/agentVersionRoutes.js
Normal file
419
backend/src/routes/agentVersionRoutes.js
Normal file
@@ -0,0 +1,419 @@
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
const agentVersionService = require("../services/agentVersionService");
|
||||
const { authenticateToken } = require("../middleware/auth");
|
||||
const { requirePermission } = require("../middleware/permissions");
|
||||
|
||||
// Test GitHub API connectivity
|
||||
router.get(
|
||||
"/test-github",
|
||||
authenticateToken,
|
||||
requirePermission("can_manage_settings"),
|
||||
async (_req, res) => {
|
||||
try {
|
||||
const axios = require("axios");
|
||||
const response = await axios.get(
|
||||
"https://api.github.com/repos/PatchMon/PatchMon-agent/releases",
|
||||
{
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
"User-Agent": "PatchMon-Server/1.0",
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
status: response.status,
|
||||
releasesFound: response.data.length,
|
||||
latestRelease: response.data[0]?.tag_name || "No releases",
|
||||
rateLimitRemaining: response.headers["x-ratelimit-remaining"],
|
||||
rateLimitLimit: response.headers["x-ratelimit-limit"],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ GitHub API test failed:", error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
rateLimitRemaining: error.response?.headers["x-ratelimit-remaining"],
|
||||
rateLimitLimit: error.response?.headers["x-ratelimit-limit"],
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Get current version information
|
||||
router.get("/version", authenticateToken, async (_req, res) => {
|
||||
try {
|
||||
const versionInfo = await agentVersionService.getVersionInfo();
|
||||
console.log(
|
||||
"📊 Version info response:",
|
||||
JSON.stringify(versionInfo, null, 2),
|
||||
);
|
||||
res.json(versionInfo);
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to get version info:", error.message);
|
||||
res.status(500).json({
|
||||
error: "Failed to get version information",
|
||||
details: error.message,
|
||||
status: "error",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Refresh current version by executing agent binary
|
||||
router.post(
|
||||
"/version/refresh",
|
||||
authenticateToken,
|
||||
requirePermission("can_manage_settings"),
|
||||
async (_req, res) => {
|
||||
try {
|
||||
console.log("🔄 Refreshing current agent version...");
|
||||
const currentVersion = await agentVersionService.refreshCurrentVersion();
|
||||
console.log("📊 Refreshed current version:", currentVersion);
|
||||
res.json({
|
||||
success: true,
|
||||
currentVersion: currentVersion,
|
||||
message: currentVersion
|
||||
? `Current version refreshed: ${currentVersion}`
|
||||
: "No agent binary found",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to refresh current version:", error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Failed to refresh current version",
|
||||
details: error.message,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Download latest update
|
||||
router.post(
|
||||
"/version/download",
|
||||
authenticateToken,
|
||||
requirePermission("can_manage_settings"),
|
||||
async (_req, res) => {
|
||||
try {
|
||||
console.log("🔄 Downloading latest agent update...");
|
||||
const downloadResult = await agentVersionService.downloadLatestUpdate();
|
||||
console.log(
|
||||
"📊 Download result:",
|
||||
JSON.stringify(downloadResult, null, 2),
|
||||
);
|
||||
res.json(downloadResult);
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to download latest update:", error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Failed to download latest update",
|
||||
details: error.message,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Check for updates
|
||||
router.post(
|
||||
"/version/check",
|
||||
authenticateToken,
|
||||
requirePermission("can_manage_settings"),
|
||||
async (_req, res) => {
|
||||
try {
|
||||
console.log("🔄 Manual update check triggered");
|
||||
const updateInfo = await agentVersionService.checkForUpdates();
|
||||
console.log(
|
||||
"📊 Update check result:",
|
||||
JSON.stringify(updateInfo, null, 2),
|
||||
);
|
||||
res.json(updateInfo);
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to check for updates:", error.message);
|
||||
res.status(500).json({ error: "Failed to check for updates" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Get available versions
|
||||
router.get("/versions", authenticateToken, async (_req, res) => {
|
||||
try {
|
||||
const versions = await agentVersionService.getAvailableVersions();
|
||||
console.log(
|
||||
"📦 Available versions response:",
|
||||
JSON.stringify(versions, null, 2),
|
||||
);
|
||||
res.json({ versions });
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to get available versions:", error.message);
|
||||
res.status(500).json({ error: "Failed to get available versions" });
|
||||
}
|
||||
});
|
||||
|
||||
// Get binary information
|
||||
router.get(
|
||||
"/binary/:version/:architecture",
|
||||
authenticateToken,
|
||||
async (_req, res) => {
|
||||
try {
|
||||
const { version, architecture } = req.params;
|
||||
const binaryInfo = await agentVersionService.getBinaryInfo(
|
||||
version,
|
||||
architecture,
|
||||
);
|
||||
res.json(binaryInfo);
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to get binary info:", error.message);
|
||||
res.status(404).json({ error: error.message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Download agent binary
|
||||
router.get(
|
||||
"/download/:version/:architecture",
|
||||
authenticateToken,
|
||||
async (_req, res) => {
|
||||
try {
|
||||
const { version, architecture } = req.params;
|
||||
|
||||
// Validate architecture
|
||||
if (!agentVersionService.supportedArchitectures.includes(architecture)) {
|
||||
return res.status(400).json({ error: "Unsupported architecture" });
|
||||
}
|
||||
|
||||
await agentVersionService.serveBinary(version, architecture, res);
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to serve binary:", error.message);
|
||||
res.status(500).json({ error: "Failed to serve binary" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Get latest binary for architecture (for agents to query)
|
||||
router.get("/latest/:architecture", async (req, res) => {
|
||||
try {
|
||||
const { architecture } = req.params;
|
||||
|
||||
// Validate architecture
|
||||
if (!agentVersionService.supportedArchitectures.includes(architecture)) {
|
||||
return res.status(400).json({ error: "Unsupported architecture" });
|
||||
}
|
||||
|
||||
const versionInfo = await agentVersionService.getVersionInfo();
|
||||
|
||||
if (!versionInfo.latestVersion) {
|
||||
return res.status(404).json({ error: "No latest version available" });
|
||||
}
|
||||
|
||||
const binaryInfo = await agentVersionService.getBinaryInfo(
|
||||
versionInfo.latestVersion,
|
||||
architecture,
|
||||
);
|
||||
|
||||
res.json({
|
||||
version: binaryInfo.version,
|
||||
architecture: binaryInfo.architecture,
|
||||
size: binaryInfo.size,
|
||||
hash: binaryInfo.hash,
|
||||
downloadUrl: `/api/v1/agent/download/${binaryInfo.version}/${binaryInfo.architecture}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to get latest binary info:", error.message);
|
||||
res.status(500).json({ error: "Failed to get latest binary information" });
|
||||
}
|
||||
});
|
||||
|
||||
// Push update notification to specific agent
|
||||
router.post(
|
||||
"/notify-update/:apiId",
|
||||
authenticateToken,
|
||||
requirePermission("admin"),
|
||||
async (_req, res) => {
|
||||
try {
|
||||
const { apiId } = req.params;
|
||||
const { version, force = false } = req.body;
|
||||
|
||||
const versionInfo = await agentVersionService.getVersionInfo();
|
||||
const targetVersion = version || versionInfo.latestVersion;
|
||||
|
||||
if (!targetVersion) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "No version specified or available" });
|
||||
}
|
||||
|
||||
// Import WebSocket service
|
||||
const { pushUpdateNotification } = require("../services/agentWs");
|
||||
|
||||
// Push update notification via WebSocket
|
||||
pushUpdateNotification(apiId, {
|
||||
version: targetVersion,
|
||||
force,
|
||||
downloadUrl: `/api/v1/agent/latest/${req.body.architecture || "linux-amd64"}`,
|
||||
message: `Update available: ${targetVersion}`,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Update notification sent to agent ${apiId}`,
|
||||
version: targetVersion,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to notify agent update:", error.message);
|
||||
res.status(500).json({ error: "Failed to notify agent update" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Push update notification to all agents
|
||||
router.post(
|
||||
"/notify-update-all",
|
||||
authenticateToken,
|
||||
requirePermission("admin"),
|
||||
async (_req, res) => {
|
||||
try {
|
||||
const { version, force = false } = req.body;
|
||||
|
||||
const versionInfo = await agentVersionService.getVersionInfo();
|
||||
const targetVersion = version || versionInfo.latestVersion;
|
||||
|
||||
if (!targetVersion) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "No version specified or available" });
|
||||
}
|
||||
|
||||
// Import WebSocket service
|
||||
const { pushUpdateNotificationToAll } = require("../services/agentWs");
|
||||
|
||||
// Push update notification to all connected agents
|
||||
const result = await pushUpdateNotificationToAll({
|
||||
version: targetVersion,
|
||||
force,
|
||||
message: `Update available: ${targetVersion}`,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Update notification sent to ${result.notifiedCount} agents`,
|
||||
version: targetVersion,
|
||||
notifiedCount: result.notifiedCount,
|
||||
failedCount: result.failedCount,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to notify all agents update:", error.message);
|
||||
res.status(500).json({ error: "Failed to notify all agents update" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Check if specific agent needs update and push notification
|
||||
router.post(
|
||||
"/check-update/:apiId",
|
||||
authenticateToken,
|
||||
requirePermission("can_manage_settings"),
|
||||
async (_req, res) => {
|
||||
try {
|
||||
const { apiId } = req.params;
|
||||
const { version, force = false } = req.body;
|
||||
|
||||
if (!version) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "Agent version is required",
|
||||
});
|
||||
}
|
||||
|
||||
console.log(
|
||||
`🔍 Checking update for agent ${apiId} (version: ${version})`,
|
||||
);
|
||||
const result = await agentVersionService.checkAndPushAgentUpdate(
|
||||
apiId,
|
||||
version,
|
||||
force,
|
||||
);
|
||||
console.log(
|
||||
"📊 Agent update check result:",
|
||||
JSON.stringify(result, null, 2),
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
...result,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to check agent update:", error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Failed to check agent update",
|
||||
details: error.message,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Push updates to all connected agents
|
||||
router.post(
|
||||
"/push-updates-all",
|
||||
authenticateToken,
|
||||
requirePermission("can_manage_settings"),
|
||||
async (_req, res) => {
|
||||
try {
|
||||
const { force = false } = req.body;
|
||||
|
||||
console.log(`🔄 Pushing updates to all agents (force: ${force})`);
|
||||
const result = await agentVersionService.checkAndPushUpdatesToAll(force);
|
||||
console.log("📊 Bulk update result:", JSON.stringify(result, null, 2));
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to push updates to all agents:", error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Failed to push updates to all agents",
|
||||
details: error.message,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Agent reports its version (for automatic update checking)
|
||||
router.post("/report-version", authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { apiId, version } = req.body;
|
||||
|
||||
if (!apiId || !version) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "API ID and version are required",
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`📊 Agent ${apiId} reported version: ${version}`);
|
||||
|
||||
// Check if agent needs update and push notification if needed
|
||||
const updateResult = await agentVersionService.checkAndPushAgentUpdate(
|
||||
apiId,
|
||||
version,
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Version reported successfully",
|
||||
updateCheck: updateResult,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to process agent version report:", error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Failed to process version report",
|
||||
details: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -123,35 +123,97 @@ router.get("/agent/download", async (req, res) => {
|
||||
});
|
||||
|
||||
// Version check endpoint for agents
|
||||
router.get("/agent/version", async (_req, res) => {
|
||||
router.get("/agent/version", async (req, res) => {
|
||||
try {
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { exec } = require("node:child_process");
|
||||
const { promisify } = require("node:util");
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Read version directly from agent script file
|
||||
const agentPath = path.join(__dirname, "../../../agents/patchmon-agent.sh");
|
||||
// Get architecture parameter (default to amd64 for Go agents)
|
||||
const architecture = req.query.arch || "amd64";
|
||||
const agentType = req.query.type || "go"; // "go" or "legacy"
|
||||
|
||||
if (!fs.existsSync(agentPath)) {
|
||||
return res.status(404).json({ error: "Agent script not found" });
|
||||
if (agentType === "legacy") {
|
||||
// Legacy agent version check (bash script)
|
||||
const agentPath = path.join(
|
||||
__dirname,
|
||||
"../../../agents/patchmon-agent.sh",
|
||||
);
|
||||
|
||||
if (!fs.existsSync(agentPath)) {
|
||||
return res.status(404).json({ error: "Legacy agent script not found" });
|
||||
}
|
||||
|
||||
const scriptContent = fs.readFileSync(agentPath, "utf8");
|
||||
const versionMatch = scriptContent.match(/AGENT_VERSION="([^"]+)"/);
|
||||
|
||||
if (!versionMatch) {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Could not extract version from agent script" });
|
||||
}
|
||||
|
||||
const currentVersion = versionMatch[1];
|
||||
|
||||
res.json({
|
||||
currentVersion: currentVersion,
|
||||
downloadUrl: `/api/v1/hosts/agent/download`,
|
||||
releaseNotes: `PatchMon Agent v${currentVersion}`,
|
||||
minServerVersion: null,
|
||||
});
|
||||
} else {
|
||||
// Go agent version check (binary)
|
||||
const binaryName = `patchmon-agent-linux-${architecture}`;
|
||||
const binaryPath = path.join(__dirname, "../../../agents", binaryName);
|
||||
|
||||
if (!fs.existsSync(binaryPath)) {
|
||||
return res.status(404).json({
|
||||
error: `Go agent binary not found for architecture: ${architecture}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Execute the binary to get its version
|
||||
try {
|
||||
const { stdout } = await execAsync(`${binaryPath} --help`, {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Parse version from help output (e.g., "PatchMon Agent v1.3.1")
|
||||
const versionMatch = stdout.match(
|
||||
/PatchMon Agent v([0-9]+\.[0-9]+\.[0-9]+)/i,
|
||||
);
|
||||
|
||||
if (!versionMatch) {
|
||||
return res.status(500).json({
|
||||
error: "Could not extract version from agent binary",
|
||||
});
|
||||
}
|
||||
|
||||
const serverVersion = versionMatch[1];
|
||||
const agentVersion = req.query.currentVersion || serverVersion;
|
||||
|
||||
// Simple version comparison (assuming semantic versioning)
|
||||
const hasUpdate = agentVersion !== serverVersion;
|
||||
|
||||
res.json({
|
||||
currentVersion: agentVersion,
|
||||
latestVersion: serverVersion,
|
||||
hasUpdate: hasUpdate,
|
||||
downloadUrl: `/api/v1/hosts/agent/download?arch=${architecture}`,
|
||||
releaseNotes: `PatchMon Agent v${serverVersion}`,
|
||||
minServerVersion: null,
|
||||
architecture: architecture,
|
||||
agentType: "go",
|
||||
});
|
||||
} catch (execError) {
|
||||
console.error("Failed to execute agent binary:", execError.message);
|
||||
return res.status(500).json({
|
||||
error: "Failed to get version from agent binary",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const scriptContent = fs.readFileSync(agentPath, "utf8");
|
||||
const versionMatch = scriptContent.match(/AGENT_VERSION="([^"]+)"/);
|
||||
|
||||
if (!versionMatch) {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Could not extract version from agent script" });
|
||||
}
|
||||
|
||||
const currentVersion = versionMatch[1];
|
||||
|
||||
res.json({
|
||||
currentVersion: currentVersion,
|
||||
downloadUrl: `/api/v1/hosts/agent/download`,
|
||||
releaseNotes: `PatchMon Agent v${currentVersion}`,
|
||||
minServerVersion: null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Version check error:", error);
|
||||
res.status(500).json({ error: "Failed to get agent version" });
|
||||
|
||||
@@ -67,6 +67,7 @@ const gethomepageRoutes = require("./routes/gethomepageRoutes");
|
||||
const automationRoutes = require("./routes/automationRoutes");
|
||||
const dockerRoutes = require("./routes/dockerRoutes");
|
||||
const wsRoutes = require("./routes/wsRoutes");
|
||||
const agentVersionRoutes = require("./routes/agentVersionRoutes");
|
||||
const { initSettings } = require("./services/settingsService");
|
||||
const { queueManager } = require("./services/automation");
|
||||
const { authenticateToken, requireAdmin } = require("./middleware/auth");
|
||||
@@ -262,6 +263,7 @@ const PORT = process.env.PORT || 3001;
|
||||
const http = require("node:http");
|
||||
const server = http.createServer(app);
|
||||
const { init: initAgentWs } = require("./services/agentWs");
|
||||
const agentVersionService = require("./services/agentVersionService");
|
||||
|
||||
// Trust proxy (needed when behind reverse proxy) and remove X-Powered-By
|
||||
if (process.env.TRUST_PROXY) {
|
||||
@@ -293,7 +295,7 @@ app.disable("x-powered-by");
|
||||
// Rate limiting with monitoring
|
||||
const limiter = rateLimit({
|
||||
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) || 15 * 60 * 1000,
|
||||
max: parseInt(process.env.RATE_LIMIT_MAX, 10) || 100,
|
||||
max: parseInt(process.env.RATE_LIMIT_MAX, 10) || 5000,
|
||||
message: {
|
||||
error: "Too many requests from this IP, please try again later.",
|
||||
retryAfter: Math.ceil(
|
||||
@@ -339,20 +341,50 @@ const parseOrigins = (val) =>
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const allowedOrigins = parseOrigins(
|
||||
process.env.CORS_ORIGINS || process.env.CORS_ORIGIN || "http://fabio:3000",
|
||||
process.env.CORS_ORIGINS ||
|
||||
process.env.CORS_ORIGIN ||
|
||||
"http://localhost:3000",
|
||||
);
|
||||
|
||||
// Add Bull Board origin to allowed origins if not already present
|
||||
const bullBoardOrigin = process.env.CORS_ORIGIN || "http://localhost:3000";
|
||||
if (!allowedOrigins.includes(bullBoardOrigin)) {
|
||||
allowedOrigins.push(bullBoardOrigin);
|
||||
}
|
||||
|
||||
app.use(
|
||||
cors({
|
||||
origin: (origin, callback) => {
|
||||
// Allow non-browser/SSR tools with no origin
|
||||
if (!origin) return callback(null, true);
|
||||
if (allowedOrigins.includes(origin)) return callback(null, true);
|
||||
|
||||
// Allow Bull Board requests from the same origin as CORS_ORIGIN
|
||||
if (origin === bullBoardOrigin) return callback(null, true);
|
||||
|
||||
// Allow same-origin requests (e.g., Bull Board accessing its own API)
|
||||
// This allows http://hostname:3001 to make requests to http://hostname:3001
|
||||
if (origin?.includes(":3001")) return callback(null, true);
|
||||
|
||||
// Allow Bull Board requests from the frontend origin (same host, different port)
|
||||
// This handles cases where frontend is on port 3000 and backend on 3001
|
||||
const frontendOrigin = origin?.replace(/:3001$/, ":3000");
|
||||
if (frontendOrigin && allowedOrigins.includes(frontendOrigin)) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
return callback(new Error("Not allowed by CORS"));
|
||||
},
|
||||
credentials: true,
|
||||
// Additional CORS options for better cookie handling
|
||||
optionsSuccessStatus: 200,
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: [
|
||||
"Content-Type",
|
||||
"Authorization",
|
||||
"Cookie",
|
||||
"X-Requested-With",
|
||||
],
|
||||
}),
|
||||
);
|
||||
app.use(limiter);
|
||||
@@ -392,7 +424,7 @@ const apiVersion = process.env.API_VERSION || "v1";
|
||||
const authLimiter = rateLimit({
|
||||
windowMs:
|
||||
parseInt(process.env.AUTH_RATE_LIMIT_WINDOW_MS, 10) || 10 * 60 * 1000,
|
||||
max: parseInt(process.env.AUTH_RATE_LIMIT_MAX, 10) || 20,
|
||||
max: parseInt(process.env.AUTH_RATE_LIMIT_MAX, 10) || 500,
|
||||
message: {
|
||||
error: "Too many authentication requests, please try again later.",
|
||||
retryAfter: Math.ceil(
|
||||
@@ -406,7 +438,7 @@ const authLimiter = rateLimit({
|
||||
});
|
||||
const agentLimiter = rateLimit({
|
||||
windowMs: parseInt(process.env.AGENT_RATE_LIMIT_WINDOW_MS, 10) || 60 * 1000,
|
||||
max: parseInt(process.env.AGENT_RATE_LIMIT_MAX, 10) || 120,
|
||||
max: parseInt(process.env.AGENT_RATE_LIMIT_MAX, 10) || 1000,
|
||||
message: {
|
||||
error: "Too many agent requests, please try again later.",
|
||||
retryAfter: Math.ceil(
|
||||
@@ -440,10 +472,11 @@ app.use(`/api/${apiVersion}/gethomepage`, gethomepageRoutes);
|
||||
app.use(`/api/${apiVersion}/automation`, automationRoutes);
|
||||
app.use(`/api/${apiVersion}/docker`, dockerRoutes);
|
||||
app.use(`/api/${apiVersion}/ws`, wsRoutes);
|
||||
app.use(`/api/${apiVersion}/agent`, agentVersionRoutes);
|
||||
|
||||
// Bull Board - will be populated after queue manager initializes
|
||||
let bullBoardRouter = null;
|
||||
const bullBoardSessions = new Map(); // Store authenticated sessions
|
||||
const _bullBoardSessions = new Map(); // Store authenticated sessions
|
||||
|
||||
// Mount Bull Board at /bullboard for cleaner URL
|
||||
app.use(`/bullboard`, (_req, res, next) => {
|
||||
@@ -453,16 +486,176 @@ app.use(`/bullboard`, (_req, res, next) => {
|
||||
res.setHeader("Cross-Origin-Embedder-Policy", "unsafe-none");
|
||||
}
|
||||
|
||||
// Add headers to help with WebSocket connections
|
||||
res.setHeader("X-Frame-Options", "SAMEORIGIN");
|
||||
res.setHeader(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; connect-src 'self' ws: wss:;",
|
||||
);
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
// Authentication middleware for Bull Board
|
||||
// Simplified Bull Board authentication - just validate token once and set a simple auth cookie
|
||||
app.use(`/bullboard`, async (req, res, next) => {
|
||||
// Skip authentication for static assets only
|
||||
// Skip authentication for static assets
|
||||
if (req.path.includes("/static/") || req.path.includes("/favicon")) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Check for existing Bull Board auth cookie
|
||||
if (req.cookies["bull-board-auth"]) {
|
||||
// Already authenticated, allow access
|
||||
return next();
|
||||
}
|
||||
|
||||
// No auth cookie - check for token in query
|
||||
const token = req.query.token;
|
||||
if (!token) {
|
||||
return res.status(401).json({
|
||||
error:
|
||||
"Authentication required. Please access Bull Board from the Automation page.",
|
||||
});
|
||||
}
|
||||
|
||||
// Validate token and set auth cookie
|
||||
req.headers.authorization = `Bearer ${token}`;
|
||||
return authenticateToken(req, res, (err) => {
|
||||
if (err) {
|
||||
return res.status(401).json({ error: "Invalid authentication token" });
|
||||
}
|
||||
return requireAdmin(req, res, (adminErr) => {
|
||||
if (adminErr) {
|
||||
return res.status(403).json({ error: "Admin access required" });
|
||||
}
|
||||
|
||||
// Set a simple auth cookie that will persist for the session
|
||||
res.cookie("bull-board-auth", token, {
|
||||
httpOnly: false,
|
||||
secure: false,
|
||||
maxAge: 3600000, // 1 hour
|
||||
path: "/bullboard",
|
||||
sameSite: "lax",
|
||||
});
|
||||
|
||||
console.log("Bull Board - Authentication successful, cookie set");
|
||||
return next();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Remove all the old complex middleware below and replace with the new Bull Board router setup
|
||||
app.use(`/bullboard`, (req, res, next) => {
|
||||
if (bullBoardRouter) {
|
||||
return bullBoardRouter(req, res, next);
|
||||
}
|
||||
return res.status(503).json({ error: "Bull Board not initialized yet" });
|
||||
});
|
||||
|
||||
/*
|
||||
// OLD MIDDLEWARE - REMOVED FOR SIMPLIFICATION - DO NOT USE
|
||||
if (false) {
|
||||
const sessionId = req.cookies["bull-board-session"];
|
||||
console.log("Bull Board API call - Session ID:", sessionId ? "present" : "missing");
|
||||
console.log("Bull Board API call - Cookies:", req.cookies);
|
||||
console.log("Bull Board API call - Bull Board token cookie:", req.cookies["bull-board-token"] ? "present" : "missing");
|
||||
console.log("Bull Board API call - Query token:", req.query.token ? "present" : "missing");
|
||||
console.log("Bull Board API call - Auth header:", req.headers.authorization ? "present" : "missing");
|
||||
console.log("Bull Board API call - Origin:", req.headers.origin || "missing");
|
||||
console.log("Bull Board API call - Referer:", req.headers.referer || "missing");
|
||||
|
||||
// Check if we have any authentication method available
|
||||
const hasSession = !!sessionId;
|
||||
const hasTokenCookie = !!req.cookies["bull-board-token"];
|
||||
const hasQueryToken = !!req.query.token;
|
||||
const hasAuthHeader = !!req.headers.authorization;
|
||||
const hasReferer = !!req.headers.referer;
|
||||
|
||||
console.log("Bull Board API call - Auth methods available:", {
|
||||
session: hasSession,
|
||||
tokenCookie: hasTokenCookie,
|
||||
queryToken: hasQueryToken,
|
||||
authHeader: hasAuthHeader,
|
||||
referer: hasReferer
|
||||
});
|
||||
|
||||
// Check for valid session first
|
||||
if (sessionId) {
|
||||
const session = bullBoardSessions.get(sessionId);
|
||||
console.log("Bull Board API call - Session found:", !!session);
|
||||
if (session && Date.now() - session.timestamp < 3600000) {
|
||||
// Valid session, extend it
|
||||
session.timestamp = Date.now();
|
||||
console.log("Bull Board API call - Using existing session, proceeding");
|
||||
return next();
|
||||
} else if (session) {
|
||||
// Expired session, remove it
|
||||
console.log("Bull Board API call - Session expired, removing");
|
||||
bullBoardSessions.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
// No valid session, check for token as fallback
|
||||
let token = req.query.token;
|
||||
if (!token && req.headers.authorization) {
|
||||
token = req.headers.authorization.replace("Bearer ", "");
|
||||
}
|
||||
if (!token && req.cookies["bull-board-token"]) {
|
||||
token = req.cookies["bull-board-token"];
|
||||
}
|
||||
|
||||
// For API calls, also check if the token is in the referer URL
|
||||
// This handles cases where the main page hasn't set the cookie yet
|
||||
if (!token && req.headers.referer) {
|
||||
try {
|
||||
const refererUrl = new URL(req.headers.referer);
|
||||
const refererToken = refererUrl.searchParams.get('token');
|
||||
if (refererToken) {
|
||||
token = refererToken;
|
||||
console.log("Bull Board API call - Token found in referer URL:", refererToken.substring(0, 20) + "...");
|
||||
} else {
|
||||
console.log("Bull Board API call - No token found in referer URL");
|
||||
// If no token in referer and no session, return 401 with redirect info
|
||||
if (!sessionId) {
|
||||
console.log("Bull Board API call - No authentication available, returning 401");
|
||||
return res.status(401).json({
|
||||
error: "Authentication required",
|
||||
message: "Please refresh the page to re-authenticate"
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Bull Board API call - Error parsing referer URL:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (token) {
|
||||
console.log("Bull Board API call - Token found, authenticating");
|
||||
// Add token to headers for authentication
|
||||
req.headers.authorization = `Bearer ${token}`;
|
||||
|
||||
// Authenticate the user
|
||||
return authenticateToken(req, res, (err) => {
|
||||
if (err) {
|
||||
console.log("Bull Board API call - Token authentication failed");
|
||||
return res.status(401).json({ error: "Authentication failed" });
|
||||
}
|
||||
return requireAdmin(req, res, (adminErr) => {
|
||||
if (adminErr) {
|
||||
console.log("Bull Board API call - Admin access required");
|
||||
return res.status(403).json({ error: "Admin access required" });
|
||||
}
|
||||
console.log("Bull Board API call - Token authentication successful");
|
||||
return next();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// No valid session or token for API calls, deny access
|
||||
console.log("Bull Board API call - No valid session or token, denying access");
|
||||
return res.status(401).json({ error: "Valid Bull Board session or token required" });
|
||||
}
|
||||
|
||||
// Check for bull-board-session cookie first
|
||||
const sessionId = req.cookies["bull-board-session"];
|
||||
if (sessionId) {
|
||||
@@ -483,6 +676,9 @@ app.use(`/bullboard`, async (req, res, next) => {
|
||||
if (!token && req.headers.authorization) {
|
||||
token = req.headers.authorization.replace("Bearer ", "");
|
||||
}
|
||||
if (!token && req.cookies["bull-board-token"]) {
|
||||
token = req.cookies["bull-board-token"];
|
||||
}
|
||||
|
||||
// If no token, deny access
|
||||
if (!token) {
|
||||
@@ -511,13 +707,23 @@ app.use(`/bullboard`, async (req, res, next) => {
|
||||
userId: req.user.id,
|
||||
});
|
||||
|
||||
// Set session cookie
|
||||
res.cookie("bull-board-session", newSessionId, {
|
||||
// Set session cookie with proper configuration for domain access
|
||||
const isHttps = process.env.NODE_ENV === "production" || process.env.SERVER_PROTOCOL === "https";
|
||||
const cookieOptions = {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
secure: isHttps,
|
||||
maxAge: 3600000, // 1 hour
|
||||
});
|
||||
path: "/", // Set path to root so it's available for all Bull Board requests
|
||||
};
|
||||
|
||||
// Configure sameSite based on protocol and environment
|
||||
if (isHttps) {
|
||||
cookieOptions.sameSite = "none"; // Required for HTTPS cross-origin
|
||||
} else {
|
||||
cookieOptions.sameSite = "lax"; // Better for HTTP same-origin
|
||||
}
|
||||
|
||||
res.cookie("bull-board-session", newSessionId, cookieOptions);
|
||||
|
||||
// Clean up old sessions periodically
|
||||
if (bullBoardSessions.size > 100) {
|
||||
@@ -533,13 +739,111 @@ app.use(`/bullboard`, async (req, res, next) => {
|
||||
});
|
||||
});
|
||||
});
|
||||
*/
|
||||
|
||||
// Second middleware block - COMMENTED OUT - using simplified version above instead
|
||||
/*
|
||||
app.use(`/bullboard`, (req, res, next) => {
|
||||
if (bullBoardRouter) {
|
||||
// If this is the main Bull Board page (not an API call), inject the token and create session
|
||||
if (!req.path.includes("/api/") && !req.path.includes("/static/") && req.path === "/bullboard") {
|
||||
const token = req.query.token;
|
||||
console.log("Bull Board main page - Token:", token ? "present" : "missing");
|
||||
console.log("Bull Board main page - Query params:", req.query);
|
||||
console.log("Bull Board main page - Origin:", req.headers.origin || "missing");
|
||||
console.log("Bull Board main page - Referer:", req.headers.referer || "missing");
|
||||
console.log("Bull Board main page - Cookies:", req.cookies);
|
||||
|
||||
if (token) {
|
||||
// Authenticate the user and create a session immediately on page load
|
||||
req.headers.authorization = `Bearer ${token}`;
|
||||
|
||||
return authenticateToken(req, res, (err) => {
|
||||
if (err) {
|
||||
console.log("Bull Board main page - Token authentication failed");
|
||||
return res.status(401).json({ error: "Authentication failed" });
|
||||
}
|
||||
return requireAdmin(req, res, (adminErr) => {
|
||||
if (adminErr) {
|
||||
console.log("Bull Board main page - Admin access required");
|
||||
return res.status(403).json({ error: "Admin access required" });
|
||||
}
|
||||
|
||||
console.log("Bull Board main page - Token authentication successful, creating session");
|
||||
|
||||
// Create a Bull Board session immediately
|
||||
const newSessionId = require("node:crypto")
|
||||
.randomBytes(32)
|
||||
.toString("hex");
|
||||
bullBoardSessions.set(newSessionId, {
|
||||
timestamp: Date.now(),
|
||||
userId: req.user.id,
|
||||
});
|
||||
|
||||
// Set session cookie with proper configuration for domain access
|
||||
const sessionCookieOptions = {
|
||||
httpOnly: true,
|
||||
secure: false, // Always false for HTTP
|
||||
maxAge: 3600000, // 1 hour
|
||||
path: "/", // Set path to root so it's available for all Bull Board requests
|
||||
sameSite: "lax", // Always lax for HTTP
|
||||
};
|
||||
|
||||
res.cookie("bull-board-session", newSessionId, sessionCookieOptions);
|
||||
console.log("Bull Board main page - Session created:", newSessionId);
|
||||
console.log("Bull Board main page - Cookie options:", sessionCookieOptions);
|
||||
|
||||
// Also set a token cookie for API calls as a fallback
|
||||
const tokenCookieOptions = {
|
||||
httpOnly: false, // Allow JavaScript to access it
|
||||
secure: false, // Always false for HTTP
|
||||
maxAge: 3600000, // 1 hour
|
||||
path: "/", // Set path to root for broader compatibility
|
||||
sameSite: "lax", // Always lax for HTTP
|
||||
};
|
||||
|
||||
res.cookie("bull-board-token", token, tokenCookieOptions);
|
||||
console.log("Bull Board main page - Token cookie also set for API fallback");
|
||||
|
||||
// Clean up old sessions periodically
|
||||
if (bullBoardSessions.size > 100) {
|
||||
const now = Date.now();
|
||||
for (const [sid, session] of bullBoardSessions.entries()) {
|
||||
if (now - session.timestamp > 3600000) {
|
||||
bullBoardSessions.delete(sid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now proceed to serve the Bull Board page
|
||||
return bullBoardRouter(req, res, next);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
console.log("Bull Board main page - No token provided, checking for existing session");
|
||||
// Check if we have an existing session
|
||||
const sessionId = req.cookies["bull-board-session"];
|
||||
if (sessionId) {
|
||||
const session = bullBoardSessions.get(sessionId);
|
||||
if (session && Date.now() - session.timestamp < 3600000) {
|
||||
console.log("Bull Board main page - Using existing session");
|
||||
// Extend session
|
||||
session.timestamp = Date.now();
|
||||
return bullBoardRouter(req, res, next);
|
||||
} else if (session) {
|
||||
console.log("Bull Board main page - Session expired, removing");
|
||||
bullBoardSessions.delete(sessionId);
|
||||
}
|
||||
}
|
||||
console.log("Bull Board main page - No valid session, denying access");
|
||||
return res.status(401).json({ error: "Access token required" });
|
||||
}
|
||||
}
|
||||
return bullBoardRouter(req, res, next);
|
||||
}
|
||||
return res.status(503).json({ error: "Bull Board not initialized yet" });
|
||||
});
|
||||
*/
|
||||
|
||||
// Error handler specifically for Bull Board routes
|
||||
app.use("/bullboard", (err, req, res, _next) => {
|
||||
@@ -892,6 +1196,7 @@ async function startServer() {
|
||||
|
||||
// Initialize WS layer with the underlying HTTP server
|
||||
initAgentWs(server, prisma);
|
||||
await agentVersionService.initialize();
|
||||
|
||||
server.listen(PORT, () => {
|
||||
if (process.env.ENABLE_LOGGING === "true") {
|
||||
|
||||
743
backend/src/services/agentVersionService.js
Normal file
743
backend/src/services/agentVersionService.js
Normal file
@@ -0,0 +1,743 @@
|
||||
const axios = require("axios");
|
||||
const fs = require("node:fs").promises;
|
||||
const path = require("node:path");
|
||||
const { exec, spawn } = require("node:child_process");
|
||||
const { promisify } = require("node:util");
|
||||
const _execAsync = promisify(exec);
|
||||
|
||||
// Simple semver comparison function
|
||||
function compareVersions(version1, version2) {
|
||||
const v1parts = version1.split(".").map(Number);
|
||||
const v2parts = version2.split(".").map(Number);
|
||||
|
||||
// Ensure both arrays have the same length
|
||||
while (v1parts.length < 3) v1parts.push(0);
|
||||
while (v2parts.length < 3) v2parts.push(0);
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (v1parts[i] > v2parts[i]) return 1;
|
||||
if (v1parts[i] < v2parts[i]) return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
class AgentVersionService {
|
||||
constructor() {
|
||||
this.githubApiUrl =
|
||||
"https://api.github.com/repos/PatchMon/PatchMon-agent/releases";
|
||||
this.agentsDir = path.resolve(__dirname, "../../../agents");
|
||||
this.supportedArchitectures = [
|
||||
"linux-amd64",
|
||||
"linux-arm64",
|
||||
"linux-386",
|
||||
"linux-arm",
|
||||
];
|
||||
this.currentVersion = null;
|
||||
this.latestVersion = null;
|
||||
this.lastChecked = null;
|
||||
this.checkInterval = 30 * 60 * 1000; // 30 minutes
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
try {
|
||||
// Ensure agents directory exists
|
||||
await fs.mkdir(this.agentsDir, { recursive: true });
|
||||
|
||||
console.log("🔍 Testing GitHub API connectivity...");
|
||||
try {
|
||||
const testResponse = await axios.get(
|
||||
"https://api.github.com/repos/PatchMon/PatchMon-agent/releases",
|
||||
{
|
||||
timeout: 5000,
|
||||
headers: {
|
||||
"User-Agent": "PatchMon-Server/1.0",
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
},
|
||||
},
|
||||
);
|
||||
console.log(
|
||||
`✅ GitHub API accessible - found ${testResponse.data.length} releases`,
|
||||
);
|
||||
} catch (testError) {
|
||||
console.error("❌ GitHub API not accessible:", testError.message);
|
||||
if (testError.response) {
|
||||
console.error(
|
||||
"❌ Status:",
|
||||
testError.response.status,
|
||||
testError.response.statusText,
|
||||
);
|
||||
if (testError.response.status === 403) {
|
||||
console.log("⚠️ GitHub API rate limit exceeded - will retry later");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get current agent version by executing the binary
|
||||
await this.getCurrentAgentVersion();
|
||||
|
||||
// Try to check for updates, but don't fail initialization if GitHub API is unavailable
|
||||
try {
|
||||
await this.checkForUpdates();
|
||||
} catch (updateError) {
|
||||
console.log(
|
||||
"⚠️ Failed to check for updates on startup, will retry later:",
|
||||
updateError.message,
|
||||
);
|
||||
}
|
||||
|
||||
// Set up periodic checking
|
||||
setInterval(() => {
|
||||
this.checkForUpdates().catch((error) => {
|
||||
console.log("⚠️ Periodic update check failed:", error.message);
|
||||
});
|
||||
}, this.checkInterval);
|
||||
|
||||
console.log("✅ Agent Version Service initialized");
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"❌ Failed to initialize Agent Version Service:",
|
||||
error.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getCurrentAgentVersion() {
|
||||
try {
|
||||
console.log("🔍 Getting current agent version...");
|
||||
|
||||
// Try to find the agent binary in agents/ folder only (what gets distributed)
|
||||
const possiblePaths = [
|
||||
path.join(this.agentsDir, "patchmon-agent-linux-amd64"),
|
||||
path.join(this.agentsDir, "patchmon-agent"),
|
||||
];
|
||||
|
||||
let agentPath = null;
|
||||
for (const testPath of possiblePaths) {
|
||||
try {
|
||||
await fs.access(testPath);
|
||||
agentPath = testPath;
|
||||
console.log(`✅ Found agent binary at: ${testPath}`);
|
||||
break;
|
||||
} catch {
|
||||
// Path doesn't exist, continue to next
|
||||
}
|
||||
}
|
||||
|
||||
if (!agentPath) {
|
||||
console.log(
|
||||
"⚠️ No agent binary found in agents/ folder, current version will be unknown",
|
||||
);
|
||||
console.log("💡 Use the Download Updates button to get agent binaries");
|
||||
this.currentVersion = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute the agent binary with help flag to get version info
|
||||
try {
|
||||
const child = spawn(agentPath, ["--help"], {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
child.stdout.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
child.on("close", (code) => {
|
||||
resolve({ stdout, stderr, code });
|
||||
});
|
||||
child.on("error", reject);
|
||||
});
|
||||
|
||||
if (result.stderr) {
|
||||
console.log("⚠️ Agent help stderr:", result.stderr);
|
||||
}
|
||||
|
||||
// Parse version from help output (e.g., "PatchMon Agent v1.3.0")
|
||||
const versionMatch = result.stdout.match(
|
||||
/PatchMon Agent v([0-9]+\.[0-9]+\.[0-9]+)/i,
|
||||
);
|
||||
if (versionMatch) {
|
||||
this.currentVersion = versionMatch[1];
|
||||
console.log(`✅ Current agent version: ${this.currentVersion}`);
|
||||
} else {
|
||||
console.log(
|
||||
"⚠️ Could not parse version from agent help output:",
|
||||
result.stdout,
|
||||
);
|
||||
this.currentVersion = null;
|
||||
}
|
||||
} catch (execError) {
|
||||
console.error("❌ Failed to execute agent binary:", execError.message);
|
||||
this.currentVersion = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to get current agent version:", error.message);
|
||||
this.currentVersion = null;
|
||||
}
|
||||
}
|
||||
|
||||
async checkForUpdates() {
|
||||
try {
|
||||
console.log("🔍 Checking for agent updates...");
|
||||
|
||||
const response = await axios.get(this.githubApiUrl, {
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
"User-Agent": "PatchMon-Server/1.0",
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`📡 GitHub API response status: ${response.status}`);
|
||||
console.log(`📦 Found ${response.data.length} releases`);
|
||||
|
||||
const releases = response.data;
|
||||
if (releases.length === 0) {
|
||||
console.log("ℹ️ No releases found");
|
||||
this.latestVersion = null;
|
||||
this.lastChecked = new Date();
|
||||
return {
|
||||
latestVersion: null,
|
||||
currentVersion: this.currentVersion,
|
||||
hasUpdate: false,
|
||||
lastChecked: this.lastChecked,
|
||||
};
|
||||
}
|
||||
|
||||
const latestRelease = releases[0];
|
||||
this.latestVersion = latestRelease.tag_name.replace("v", ""); // Remove 'v' prefix
|
||||
this.lastChecked = new Date();
|
||||
|
||||
console.log(`📦 Latest agent version: ${this.latestVersion}`);
|
||||
|
||||
// Don't download binaries automatically - only when explicitly requested
|
||||
console.log(
|
||||
"ℹ️ Skipping automatic binary download - binaries will be downloaded on demand",
|
||||
);
|
||||
|
||||
return {
|
||||
latestVersion: this.latestVersion,
|
||||
currentVersion: this.currentVersion,
|
||||
hasUpdate: this.currentVersion !== this.latestVersion,
|
||||
lastChecked: this.lastChecked,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to check for updates:", error.message);
|
||||
if (error.response) {
|
||||
console.error(
|
||||
"❌ GitHub API error:",
|
||||
error.response.status,
|
||||
error.response.statusText,
|
||||
);
|
||||
console.error(
|
||||
"❌ Rate limit info:",
|
||||
error.response.headers["x-ratelimit-remaining"],
|
||||
"/",
|
||||
error.response.headers["x-ratelimit-limit"],
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async downloadBinariesToAgentsFolder(release) {
|
||||
try {
|
||||
console.log(
|
||||
`⬇️ Downloading binaries for version ${release.tag_name} to agents folder...`,
|
||||
);
|
||||
|
||||
for (const arch of this.supportedArchitectures) {
|
||||
const assetName = `patchmon-agent-${arch}`;
|
||||
const asset = release.assets.find((a) => a.name === assetName);
|
||||
|
||||
if (!asset) {
|
||||
console.warn(`⚠️ Binary not found for architecture: ${arch}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const binaryPath = path.join(this.agentsDir, assetName);
|
||||
|
||||
console.log(`⬇️ Downloading ${assetName}...`);
|
||||
|
||||
const response = await axios.get(asset.browser_download_url, {
|
||||
responseType: "stream",
|
||||
timeout: 60000,
|
||||
});
|
||||
|
||||
const writer = require("node:fs").createWriteStream(binaryPath);
|
||||
response.data.pipe(writer);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
writer.on("finish", resolve);
|
||||
writer.on("error", reject);
|
||||
});
|
||||
|
||||
// Make executable
|
||||
await fs.chmod(binaryPath, "755");
|
||||
|
||||
console.log(`✅ Downloaded: ${assetName} to agents folder`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"❌ Failed to download binaries to agents folder:",
|
||||
error.message,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async downloadBinaryForVersion(version, architecture) {
|
||||
try {
|
||||
console.log(
|
||||
`⬇️ Downloading binary for version ${version} architecture ${architecture}...`,
|
||||
);
|
||||
|
||||
// Get the release info from GitHub
|
||||
const response = await axios.get(this.githubApiUrl, {
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
"User-Agent": "PatchMon-Server/1.0",
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
},
|
||||
});
|
||||
|
||||
const releases = response.data;
|
||||
const release = releases.find(
|
||||
(r) => r.tag_name.replace("v", "") === version,
|
||||
);
|
||||
|
||||
if (!release) {
|
||||
throw new Error(`Release ${version} not found`);
|
||||
}
|
||||
|
||||
const assetName = `patchmon-agent-${architecture}`;
|
||||
const asset = release.assets.find((a) => a.name === assetName);
|
||||
|
||||
if (!asset) {
|
||||
throw new Error(`Binary not found for architecture: ${architecture}`);
|
||||
}
|
||||
|
||||
const binaryPath = path.join(
|
||||
this.agentBinariesDir,
|
||||
`${release.tag_name}-${assetName}`,
|
||||
);
|
||||
|
||||
console.log(`⬇️ Downloading ${assetName}...`);
|
||||
|
||||
const downloadResponse = await axios.get(asset.browser_download_url, {
|
||||
responseType: "stream",
|
||||
timeout: 60000,
|
||||
});
|
||||
|
||||
const writer = require("node:fs").createWriteStream(binaryPath);
|
||||
downloadResponse.data.pipe(writer);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
writer.on("finish", resolve);
|
||||
writer.on("error", reject);
|
||||
});
|
||||
|
||||
// Make executable
|
||||
await fs.chmod(binaryPath, "755");
|
||||
|
||||
console.log(`✅ Downloaded: ${assetName}`);
|
||||
return binaryPath;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`❌ Failed to download binary ${version}-${architecture}:`,
|
||||
error.message,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getBinaryPath(version, architecture) {
|
||||
const binaryName = `patchmon-agent-${architecture}`;
|
||||
const binaryPath = path.join(this.agentsDir, binaryName);
|
||||
|
||||
try {
|
||||
await fs.access(binaryPath);
|
||||
return binaryPath;
|
||||
} catch {
|
||||
throw new Error(`Binary not found: ${binaryName} version ${version}`);
|
||||
}
|
||||
}
|
||||
|
||||
async serveBinary(version, architecture, res) {
|
||||
try {
|
||||
// Check if binary exists, if not download it
|
||||
const binaryPath = await this.getBinaryPath(version, architecture);
|
||||
const stats = await fs.stat(binaryPath);
|
||||
|
||||
res.setHeader("Content-Type", "application/octet-stream");
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="patchmon-agent-${architecture}"`,
|
||||
);
|
||||
res.setHeader("Content-Length", stats.size);
|
||||
|
||||
// Add cache headers
|
||||
res.setHeader("Cache-Control", "public, max-age=3600");
|
||||
res.setHeader("ETag", `"${version}-${architecture}"`);
|
||||
|
||||
const stream = require("node:fs").createReadStream(binaryPath);
|
||||
stream.pipe(res);
|
||||
} catch (_error) {
|
||||
// Binary doesn't exist, try to download it
|
||||
console.log(
|
||||
`⬇️ Binary not found locally, attempting to download ${version}-${architecture}...`,
|
||||
);
|
||||
try {
|
||||
await this.downloadBinaryForVersion(version, architecture);
|
||||
// Retry serving the binary
|
||||
const binaryPath = await this.getBinaryPath(version, architecture);
|
||||
const stats = await fs.stat(binaryPath);
|
||||
|
||||
res.setHeader("Content-Type", "application/octet-stream");
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="patchmon-agent-${architecture}"`,
|
||||
);
|
||||
res.setHeader("Content-Length", stats.size);
|
||||
res.setHeader("Cache-Control", "public, max-age=3600");
|
||||
res.setHeader("ETag", `"${version}-${architecture}"`);
|
||||
|
||||
const stream = require("node:fs").createReadStream(binaryPath);
|
||||
stream.pipe(res);
|
||||
} catch (downloadError) {
|
||||
console.error(
|
||||
`❌ Failed to download binary ${version}-${architecture}:`,
|
||||
downloadError.message,
|
||||
);
|
||||
res
|
||||
.status(404)
|
||||
.json({ error: "Binary not found and could not be downloaded" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getVersionInfo() {
|
||||
let hasUpdate = false;
|
||||
let updateStatus = "unknown";
|
||||
let effectiveLatestVersion = this.currentVersion; // Always use local version if available
|
||||
|
||||
// If we have a local version, use it as the latest regardless of GitHub
|
||||
if (this.currentVersion) {
|
||||
effectiveLatestVersion = this.currentVersion;
|
||||
console.log(
|
||||
`🔄 Using local agent version ${this.currentVersion} as latest`,
|
||||
);
|
||||
} else if (this.latestVersion) {
|
||||
// Fallback to GitHub version only if no local version
|
||||
effectiveLatestVersion = this.latestVersion;
|
||||
console.log(
|
||||
`🔄 No local version found, using GitHub version ${this.latestVersion}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.currentVersion && effectiveLatestVersion) {
|
||||
const comparison = compareVersions(
|
||||
this.currentVersion,
|
||||
effectiveLatestVersion,
|
||||
);
|
||||
if (comparison < 0) {
|
||||
hasUpdate = true;
|
||||
updateStatus = "update-available";
|
||||
} else if (comparison > 0) {
|
||||
hasUpdate = false;
|
||||
updateStatus = "newer-version";
|
||||
} else {
|
||||
hasUpdate = false;
|
||||
updateStatus = "up-to-date";
|
||||
}
|
||||
} else if (effectiveLatestVersion && !this.currentVersion) {
|
||||
hasUpdate = true;
|
||||
updateStatus = "no-agent";
|
||||
} else if (this.currentVersion && !effectiveLatestVersion) {
|
||||
// We have a current version but no latest version (GitHub API unavailable)
|
||||
hasUpdate = false;
|
||||
updateStatus = "github-unavailable";
|
||||
} else if (!this.currentVersion && !effectiveLatestVersion) {
|
||||
updateStatus = "no-data";
|
||||
}
|
||||
|
||||
return {
|
||||
currentVersion: this.currentVersion,
|
||||
latestVersion: effectiveLatestVersion,
|
||||
hasUpdate: hasUpdate,
|
||||
updateStatus: updateStatus,
|
||||
lastChecked: this.lastChecked,
|
||||
supportedArchitectures: this.supportedArchitectures,
|
||||
status: effectiveLatestVersion ? "ready" : "no-releases",
|
||||
};
|
||||
}
|
||||
|
||||
async refreshCurrentVersion() {
|
||||
await this.getCurrentAgentVersion();
|
||||
return this.currentVersion;
|
||||
}
|
||||
|
||||
async downloadLatestUpdate() {
|
||||
try {
|
||||
console.log("⬇️ Downloading latest agent update...");
|
||||
|
||||
// First check for updates to get the latest release info
|
||||
const _updateInfo = await this.checkForUpdates();
|
||||
|
||||
if (!this.latestVersion) {
|
||||
throw new Error("No latest version available to download");
|
||||
}
|
||||
|
||||
// Get the release info from GitHub
|
||||
const response = await axios.get(this.githubApiUrl, {
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
"User-Agent": "PatchMon-Server/1.0",
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
},
|
||||
});
|
||||
|
||||
const releases = response.data;
|
||||
const latestRelease = releases[0];
|
||||
|
||||
if (!latestRelease) {
|
||||
throw new Error("No releases found");
|
||||
}
|
||||
|
||||
console.log(
|
||||
`⬇️ Downloading binaries for version ${latestRelease.tag_name}...`,
|
||||
);
|
||||
|
||||
// Download binaries for all architectures directly to agents folder
|
||||
await this.downloadBinariesToAgentsFolder(latestRelease);
|
||||
|
||||
console.log("✅ Latest update downloaded successfully");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
version: this.latestVersion,
|
||||
downloadedArchitectures: this.supportedArchitectures,
|
||||
message: `Successfully downloaded version ${this.latestVersion}`,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to download latest update:", error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getAvailableVersions() {
|
||||
// No local caching - only return latest from GitHub
|
||||
if (this.latestVersion) {
|
||||
return [this.latestVersion];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async getBinaryInfo(version, architecture) {
|
||||
try {
|
||||
// Always use local version if it matches the requested version
|
||||
if (version === this.currentVersion && this.currentVersion) {
|
||||
const binaryPath = await this.getBinaryPath(
|
||||
this.currentVersion,
|
||||
architecture,
|
||||
);
|
||||
const stats = await fs.stat(binaryPath);
|
||||
|
||||
// Calculate file hash
|
||||
const fileBuffer = await fs.readFile(binaryPath);
|
||||
const hash = crypto
|
||||
.createHash("sha256")
|
||||
.update(fileBuffer)
|
||||
.digest("hex");
|
||||
|
||||
return {
|
||||
version: this.currentVersion,
|
||||
architecture,
|
||||
size: stats.size,
|
||||
hash,
|
||||
lastModified: stats.mtime,
|
||||
path: binaryPath,
|
||||
};
|
||||
}
|
||||
|
||||
// For other versions, try to find them in the agents folder
|
||||
const binaryPath = await this.getBinaryPath(version, architecture);
|
||||
const stats = await fs.stat(binaryPath);
|
||||
|
||||
// Calculate file hash
|
||||
const fileBuffer = await fs.readFile(binaryPath);
|
||||
const hash = crypto.createHash("sha256").update(fileBuffer).digest("hex");
|
||||
|
||||
return {
|
||||
version,
|
||||
architecture,
|
||||
size: stats.size,
|
||||
hash,
|
||||
lastModified: stats.mtime,
|
||||
path: binaryPath,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to get binary info: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an agent needs an update and push notification if needed
|
||||
* @param {string} agentApiId - The agent's API ID
|
||||
* @param {string} agentVersion - The agent's current version
|
||||
* @param {boolean} force - Force update regardless of version
|
||||
* @returns {Object} Update check result
|
||||
*/
|
||||
async checkAndPushAgentUpdate(agentApiId, agentVersion, force = false) {
|
||||
try {
|
||||
console.log(
|
||||
`🔍 Checking update for agent ${agentApiId} (version: ${agentVersion})`,
|
||||
);
|
||||
|
||||
// Get current server version info
|
||||
const versionInfo = await this.getVersionInfo();
|
||||
|
||||
if (!versionInfo.latestVersion) {
|
||||
console.log(`⚠️ No latest version available for agent ${agentApiId}`);
|
||||
return {
|
||||
needsUpdate: false,
|
||||
reason: "no-latest-version",
|
||||
message: "No latest version available on server",
|
||||
};
|
||||
}
|
||||
|
||||
// Compare versions
|
||||
const comparison = compareVersions(
|
||||
agentVersion,
|
||||
versionInfo.latestVersion,
|
||||
);
|
||||
const needsUpdate = force || comparison < 0;
|
||||
|
||||
if (needsUpdate) {
|
||||
console.log(
|
||||
`📤 Agent ${agentApiId} needs update: ${agentVersion} → ${versionInfo.latestVersion}`,
|
||||
);
|
||||
|
||||
// Import agentWs service to push notification
|
||||
const { pushUpdateNotification } = require("./agentWs");
|
||||
|
||||
const updateInfo = {
|
||||
version: versionInfo.latestVersion,
|
||||
force: force,
|
||||
downloadUrl: `/api/v1/agent/binary/${versionInfo.latestVersion}/linux-amd64`,
|
||||
message: force
|
||||
? "Force update requested"
|
||||
: `Update available: ${versionInfo.latestVersion}`,
|
||||
};
|
||||
|
||||
const pushed = pushUpdateNotification(agentApiId, updateInfo);
|
||||
|
||||
if (pushed) {
|
||||
console.log(`✅ Update notification pushed to agent ${agentApiId}`);
|
||||
return {
|
||||
needsUpdate: true,
|
||||
reason: force ? "force-update" : "version-outdated",
|
||||
message: `Update notification sent: ${agentVersion} → ${versionInfo.latestVersion}`,
|
||||
targetVersion: versionInfo.latestVersion,
|
||||
};
|
||||
} else {
|
||||
console.log(
|
||||
`⚠️ Failed to push update notification to agent ${agentApiId} (not connected)`,
|
||||
);
|
||||
return {
|
||||
needsUpdate: true,
|
||||
reason: "agent-offline",
|
||||
message: "Agent needs update but is not connected",
|
||||
targetVersion: versionInfo.latestVersion,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
console.log(`✅ Agent ${agentApiId} is up to date: ${agentVersion}`);
|
||||
return {
|
||||
needsUpdate: false,
|
||||
reason: "up-to-date",
|
||||
message: `Agent is up to date: ${agentVersion}`,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`❌ Failed to check update for agent ${agentApiId}:`,
|
||||
error.message,
|
||||
);
|
||||
return {
|
||||
needsUpdate: false,
|
||||
reason: "error",
|
||||
message: `Error checking update: ${error.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check and push updates to all connected agents
|
||||
* @param {boolean} force - Force update regardless of version
|
||||
* @returns {Object} Bulk update result
|
||||
*/
|
||||
async checkAndPushUpdatesToAll(force = false) {
|
||||
try {
|
||||
console.log(
|
||||
`🔍 Checking updates for all connected agents (force: ${force})`,
|
||||
);
|
||||
|
||||
// Import agentWs service to get connected agents
|
||||
const { pushUpdateNotificationToAll } = require("./agentWs");
|
||||
|
||||
const versionInfo = await this.getVersionInfo();
|
||||
|
||||
if (!versionInfo.latestVersion) {
|
||||
return {
|
||||
success: false,
|
||||
message: "No latest version available on server",
|
||||
updatedAgents: 0,
|
||||
totalAgents: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const updateInfo = {
|
||||
version: versionInfo.latestVersion,
|
||||
force: force,
|
||||
downloadUrl: `/api/v1/agent/binary/${versionInfo.latestVersion}/linux-amd64`,
|
||||
message: force
|
||||
? "Force update requested for all agents"
|
||||
: `Update available: ${versionInfo.latestVersion}`,
|
||||
};
|
||||
|
||||
const result = await pushUpdateNotificationToAll(updateInfo);
|
||||
|
||||
console.log(
|
||||
`✅ Bulk update notification sent to ${result.notifiedCount} agents`,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Update notifications sent to ${result.notifiedCount} agents`,
|
||||
updatedAgents: result.notifiedCount,
|
||||
totalAgents: result.totalAgents,
|
||||
targetVersion: versionInfo.latestVersion,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to push updates to all agents:", error.message);
|
||||
return {
|
||||
success: false,
|
||||
message: `Error pushing updates: ${error.message}`,
|
||||
updatedAgents: 0,
|
||||
totalAgents: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AgentVersionService();
|
||||
@@ -26,7 +26,37 @@ function init(server, prismaClient) {
|
||||
server.on("upgrade", async (request, socket, head) => {
|
||||
try {
|
||||
const { pathname } = url.parse(request.url);
|
||||
if (!pathname || !pathname.startsWith("/api/")) {
|
||||
if (!pathname) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Bull Board WebSocket connections
|
||||
if (pathname.startsWith("/bullboard")) {
|
||||
// For Bull Board, we need to check if the user is authenticated
|
||||
// Check for session cookie or authorization header
|
||||
const sessionCookie = request.headers.cookie?.match(
|
||||
/bull-board-session=([^;]+)/,
|
||||
)?.[1];
|
||||
const authHeader = request.headers.authorization;
|
||||
|
||||
if (!sessionCookie && !authHeader) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// Accept the WebSocket connection for Bull Board
|
||||
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
ws.on("message", (message) => {
|
||||
// Echo back for Bull Board WebSocket
|
||||
ws.send(message);
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle agent WebSocket connections
|
||||
if (!pathname.startsWith("/api/")) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
@@ -132,6 +162,66 @@ function pushSettingsUpdate(apiId, newInterval) {
|
||||
);
|
||||
}
|
||||
|
||||
function pushUpdateNotification(apiId, updateInfo) {
|
||||
const ws = apiIdToSocket.get(apiId);
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
safeSend(
|
||||
ws,
|
||||
JSON.stringify({
|
||||
type: "update_notification",
|
||||
version: updateInfo.version,
|
||||
force: updateInfo.force || false,
|
||||
downloadUrl: updateInfo.downloadUrl,
|
||||
message: updateInfo.message,
|
||||
}),
|
||||
);
|
||||
console.log(
|
||||
`📤 Pushed update notification to agent ${apiId}: version ${updateInfo.version}`,
|
||||
);
|
||||
return true;
|
||||
} else {
|
||||
console.log(
|
||||
`⚠️ Agent ${apiId} not connected, cannot push update notification`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function pushUpdateNotificationToAll(updateInfo) {
|
||||
let notifiedCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
for (const [apiId, ws] of apiIdToSocket) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
safeSend(
|
||||
ws,
|
||||
JSON.stringify({
|
||||
type: "update_notification",
|
||||
version: updateInfo.version,
|
||||
force: updateInfo.force || false,
|
||||
message: updateInfo.message,
|
||||
}),
|
||||
);
|
||||
notifiedCount++;
|
||||
console.log(
|
||||
`📤 Pushed update notification to agent ${apiId}: version ${updateInfo.version}`,
|
||||
);
|
||||
} catch (error) {
|
||||
failedCount++;
|
||||
console.error(`❌ Failed to notify agent ${apiId}:`, error.message);
|
||||
}
|
||||
} else {
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`📤 Update notification sent to ${notifiedCount} agents, ${failedCount} failed`,
|
||||
);
|
||||
return { notifiedCount, failedCount };
|
||||
}
|
||||
|
||||
// Notify all subscribers when connection status changes
|
||||
function notifyConnectionChange(apiId, connected) {
|
||||
const subscribers = connectionChangeSubscribers.get(apiId);
|
||||
@@ -170,6 +260,8 @@ module.exports = {
|
||||
broadcastSettingsUpdate,
|
||||
pushReportNow,
|
||||
pushSettingsUpdate,
|
||||
pushUpdateNotification,
|
||||
pushUpdateNotificationToAll,
|
||||
// Expose read-only view of connected agents
|
||||
getConnectedApiIds: () => Array.from(apiIdToSocket.keys()),
|
||||
isConnected: (apiId) => {
|
||||
|
||||
@@ -8,7 +8,7 @@ ENV NODE_ENV=development \
|
||||
PM_LOG_TO_CONSOLE=true \
|
||||
PORT=3001
|
||||
|
||||
RUN apk add --no-cache openssl tini curl
|
||||
RUN apk add --no-cache openssl tini curl libc6-compat
|
||||
|
||||
USER node
|
||||
|
||||
@@ -64,7 +64,7 @@ ENV NODE_ENV=production \
|
||||
JWT_REFRESH_EXPIRES_IN=7d \
|
||||
SESSION_INACTIVITY_TIMEOUT_MINUTES=30
|
||||
|
||||
RUN apk add --no-cache openssl tini curl
|
||||
RUN apk add --no-cache openssl tini curl libc6-compat
|
||||
|
||||
USER node
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ log() {
|
||||
echo "[$(date +'%Y-%m-%d %H:%M:%S')] $*" >&2
|
||||
}
|
||||
|
||||
# Function to extract version from agent script
|
||||
# Function to extract version from agent script (legacy)
|
||||
get_agent_version() {
|
||||
local file="$1"
|
||||
if [ -f "$file" ]; then
|
||||
@@ -18,6 +18,32 @@ get_agent_version() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to get version from binary using --help flag
|
||||
get_binary_version() {
|
||||
local binary="$1"
|
||||
if [ -f "$binary" ]; then
|
||||
# Make sure binary is executable
|
||||
chmod +x "$binary" 2>/dev/null || true
|
||||
|
||||
# Try to execute the binary and extract version from help output
|
||||
# The Go binary shows version in the --help output as "PatchMon Agent v1.3.0"
|
||||
local version=$("$binary" --help 2>&1 | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' | head -n 1 | tr -d 'v')
|
||||
if [ -n "$version" ]; then
|
||||
echo "$version"
|
||||
else
|
||||
# Fallback: try --version flag
|
||||
version=$("$binary" --version 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n 1)
|
||||
if [ -n "$version" ]; then
|
||||
echo "$version"
|
||||
else
|
||||
echo "0.0.0"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "0.0.0"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to compare versions (returns 0 if $1 > $2)
|
||||
version_greater() {
|
||||
# Use sort -V for version comparison
|
||||
@@ -28,6 +54,8 @@ version_greater() {
|
||||
update_agents() {
|
||||
local backup_agent="/app/agents_backup/patchmon-agent.sh"
|
||||
local current_agent="/app/agents/patchmon-agent.sh"
|
||||
local backup_binary="/app/agents_backup/patchmon-agent-linux-amd64"
|
||||
local current_binary="/app/agents/patchmon-agent-linux-amd64"
|
||||
|
||||
# Check if agents directory exists
|
||||
if [ ! -d "/app/agents" ]; then
|
||||
@@ -41,51 +69,72 @@ update_agents() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Get versions
|
||||
local backup_version=$(get_agent_version "$backup_agent")
|
||||
local current_version=$(get_agent_version "$current_agent")
|
||||
# Get versions from both script and binary
|
||||
local backup_script_version=$(get_agent_version "$backup_agent")
|
||||
local current_script_version=$(get_agent_version "$current_agent")
|
||||
local backup_binary_version=$(get_binary_version "$backup_binary")
|
||||
local current_binary_version=$(get_binary_version "$current_binary")
|
||||
|
||||
log "Agent version check:"
|
||||
log " Image version: ${backup_version}"
|
||||
log " Volume version: ${current_version}"
|
||||
log " Image script version: ${backup_script_version}"
|
||||
log " Volume script version: ${current_script_version}"
|
||||
log " Image binary version: ${backup_binary_version}"
|
||||
log " Volume binary version: ${current_binary_version}"
|
||||
|
||||
# Determine if update is needed
|
||||
local needs_update=0
|
||||
|
||||
# Case 1: No agents in volume (first time setup)
|
||||
if [ -z "$(find /app/agents -maxdepth 1 -type f -name '*.sh' 2>/dev/null | head -n 1)" ]; then
|
||||
# Case 1: No agents in volume at all (first time setup)
|
||||
if [ -z "$(find /app/agents -maxdepth 1 -type f 2>/dev/null | head -n 1)" ]; then
|
||||
log "Agents directory is empty - performing initial copy"
|
||||
needs_update=1
|
||||
# Case 2: Backup version is newer
|
||||
elif version_greater "$backup_version" "$current_version"; then
|
||||
log "Newer agent version available (${backup_version} > ${current_version})"
|
||||
# Case 2: Binary exists but backup binary is newer
|
||||
elif [ "$current_binary_version" != "0.0.0" ] && version_greater "$backup_binary_version" "$current_binary_version"; then
|
||||
log "Newer agent binary available (${backup_binary_version} > ${current_binary_version})"
|
||||
needs_update=1
|
||||
# Case 3: No binary in volume, but shell scripts exist (legacy setup) - copy binaries
|
||||
elif [ "$current_binary_version" = "0.0.0" ] && [ "$backup_binary_version" != "0.0.0" ]; then
|
||||
log "No binary found in volume but backup has binaries - performing update"
|
||||
needs_update=1
|
||||
else
|
||||
log "Agents are up to date"
|
||||
log "Agents are up to date (binary: ${current_binary_version})"
|
||||
needs_update=0
|
||||
fi
|
||||
|
||||
# Perform update if needed
|
||||
if [ $needs_update -eq 1 ]; then
|
||||
log "Updating agents to version ${backup_version}..."
|
||||
log "Updating agents to version ${backup_binary_version}..."
|
||||
|
||||
# Create backup of existing agents if they exist
|
||||
if [ -f "$current_agent" ]; then
|
||||
if [ -f "$current_agent" ] || [ -f "$current_binary" ]; then
|
||||
local backup_timestamp=$(date +%Y%m%d_%H%M%S)
|
||||
local backup_name="/app/agents/patchmon-agent.sh.backup.${backup_timestamp}"
|
||||
cp "$current_agent" "$backup_name" 2>/dev/null || true
|
||||
log "Previous agent backed up to: $(basename $backup_name)"
|
||||
mkdir -p "/app/agents/backups"
|
||||
|
||||
# Backup shell script if it exists
|
||||
if [ -f "$current_agent" ]; then
|
||||
cp "$current_agent" "/app/agents/backups/patchmon-agent.sh.${backup_timestamp}" 2>/dev/null || true
|
||||
log "Previous script backed up"
|
||||
fi
|
||||
|
||||
# Backup binary if it exists
|
||||
if [ -f "$current_binary" ]; then
|
||||
cp "$current_binary" "/app/agents/backups/patchmon-agent-linux-amd64.${backup_timestamp}" 2>/dev/null || true
|
||||
log "Previous binary backed up"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Copy new agents
|
||||
# Copy new agents (both scripts and binaries)
|
||||
cp -r /app/agents_backup/* /app/agents/
|
||||
|
||||
# Make agent binaries executable
|
||||
chmod +x /app/agents/patchmon-agent-linux-* 2>/dev/null || true
|
||||
|
||||
# Verify update
|
||||
local new_version=$(get_agent_version "$current_agent")
|
||||
if [ "$new_version" = "$backup_version" ]; then
|
||||
log "✅ Agents successfully updated to version ${new_version}"
|
||||
local new_binary_version=$(get_binary_version "$current_binary")
|
||||
if [ "$new_binary_version" = "$backup_binary_version" ]; then
|
||||
log "✅ Agents successfully updated to version ${new_binary_version}"
|
||||
else
|
||||
log "⚠️ Warning: Agent update may have failed (expected: ${backup_version}, got: ${new_version})"
|
||||
log "⚠️ Warning: Agent update may have failed (expected: ${backup_binary_version}, got: ${new_binary_version})"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -50,6 +50,13 @@ services:
|
||||
SERVER_HOST: localhost
|
||||
SERVER_PORT: 3000
|
||||
CORS_ORIGIN: http://localhost:3000
|
||||
# Rate Limiting (times in milliseconds)
|
||||
RATE_LIMIT_WINDOW_MS: 900000
|
||||
RATE_LIMIT_MAX: 5000
|
||||
AUTH_RATE_LIMIT_WINDOW_MS: 600000
|
||||
AUTH_RATE_LIMIT_MAX: 500
|
||||
AGENT_RATE_LIMIT_WINDOW_MS: 60000
|
||||
AGENT_RATE_LIMIT_MAX: 1000
|
||||
# Redis Configuration
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: 6379
|
||||
|
||||
@@ -56,6 +56,13 @@ services:
|
||||
SERVER_HOST: localhost
|
||||
SERVER_PORT: 3000
|
||||
CORS_ORIGIN: http://localhost:3000
|
||||
# Rate Limiting (times in milliseconds)
|
||||
RATE_LIMIT_WINDOW_MS: 900000
|
||||
RATE_LIMIT_MAX: 5000
|
||||
AUTH_RATE_LIMIT_WINDOW_MS: 600000
|
||||
AUTH_RATE_LIMIT_MAX: 500
|
||||
AGENT_RATE_LIMIT_WINDOW_MS: 60000
|
||||
AGENT_RATE_LIMIT_MAX: 1000
|
||||
# Redis Configuration
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: 6379
|
||||
|
||||
@@ -24,27 +24,31 @@ server {
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Handle client-side routing
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Bull Board proxy
|
||||
# Bull Board proxy - must come before the root location to avoid conflicts
|
||||
location /bullboard {
|
||||
proxy_pass http://${BACKEND_HOST}:${BACKEND_PORT};
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header Cookie $http_cookie; # Forward cookies to backend
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 75s;
|
||||
|
||||
# Enable cookie passthrough in both directions
|
||||
proxy_pass_header Set-Cookie;
|
||||
proxy_cookie_path / /;
|
||||
|
||||
# Preserve original client IP through proxy chain
|
||||
proxy_set_header X-Original-Forwarded-For $http_x_forwarded_for;
|
||||
|
||||
# CORS headers for Bull Board
|
||||
add_header Access-Control-Allow-Origin * always;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization" always;
|
||||
# CORS headers for Bull Board - let backend handle CORS properly
|
||||
# Note: Backend handles CORS with proper origin validation and credentials
|
||||
|
||||
# Handle preflight requests
|
||||
if ($request_method = 'OPTIONS') {
|
||||
@@ -52,6 +56,11 @@ server {
|
||||
}
|
||||
}
|
||||
|
||||
# Handle client-side routing
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API proxy
|
||||
location /api/ {
|
||||
proxy_pass http://${BACKEND_HOST}:${BACKEND_PORT};
|
||||
@@ -61,13 +70,19 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
|
||||
# For the Websocket connection:
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 75s;
|
||||
|
||||
# Preserve original client IP through proxy chain
|
||||
proxy_set_header X-Original-Forwarded-For $http_x_forwarded_for;
|
||||
|
||||
# CORS headers for API calls - even though backend is doing it
|
||||
add_header Access-Control-Allow-Origin * always;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization" always;
|
||||
# CORS headers for API calls - let backend handle CORS properly
|
||||
# Note: Backend handles CORS with proper origin validation and credentials
|
||||
|
||||
# Handle preflight requests
|
||||
if ($request_method = 'OPTIONS') {
|
||||
@@ -76,8 +91,8 @@ server {
|
||||
}
|
||||
|
||||
|
||||
# Static assets caching
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
# Static assets caching (exclude Bull Board assets)
|
||||
location ~* ^/(?!bullboard).*\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
10
frontend/env.example
Normal file
10
frontend/env.example
Normal file
@@ -0,0 +1,10 @@
|
||||
# Frontend Environment Configuration
|
||||
# This file is used by Vite during build and runtime
|
||||
|
||||
# API URL - Update this to match your backend server
|
||||
VITE_API_URL=http://localhost:3001/api/v1
|
||||
|
||||
# Application Metadata
|
||||
VITE_APP_NAME=PatchMon
|
||||
VITE_APP_VERSION=1.3.0
|
||||
|
||||
@@ -1,388 +1,453 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { AlertCircle, Code, Download, Plus, Shield, X } from "lucide-react";
|
||||
import { useId, useState } from "react";
|
||||
import { agentFileAPI, settingsAPI } from "../../utils/api";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Download,
|
||||
ExternalLink,
|
||||
RefreshCw,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import api from "../../utils/api";
|
||||
|
||||
const AgentManagementTab = () => {
|
||||
const scriptFileId = useId();
|
||||
const scriptContentId = useId();
|
||||
const [showUploadModal, setShowUploadModal] = useState(false);
|
||||
const _queryClient = useQueryClient();
|
||||
const [toast, setToast] = useState(null);
|
||||
|
||||
// Agent file queries and mutations
|
||||
const {
|
||||
data: agentFileInfo,
|
||||
isLoading: agentFileLoading,
|
||||
error: agentFileError,
|
||||
refetch: refetchAgentFile,
|
||||
} = useQuery({
|
||||
queryKey: ["agentFile"],
|
||||
queryFn: () => agentFileAPI.getInfo().then((res) => res.data),
|
||||
});
|
||||
// Auto-hide toast after 5 seconds
|
||||
useEffect(() => {
|
||||
if (toast) {
|
||||
const timer = setTimeout(() => {
|
||||
setToast(null);
|
||||
}, 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
// Fetch settings for dynamic curl flags
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: () => settingsAPI.get().then((res) => res.data),
|
||||
});
|
||||
|
||||
// Helper function to get curl flags based on settings
|
||||
const _getCurlFlags = () => {
|
||||
return settings?.ignore_ssl_self_signed ? "-sk" : "-s";
|
||||
const showToast = (message, type = "success") => {
|
||||
setToast({ message, type });
|
||||
};
|
||||
|
||||
const uploadAgentMutation = useMutation({
|
||||
mutationFn: (scriptContent) =>
|
||||
agentFileAPI.upload(scriptContent).then((res) => res.data),
|
||||
// Agent version queries
|
||||
const {
|
||||
data: versionInfo,
|
||||
isLoading: versionLoading,
|
||||
error: versionError,
|
||||
refetch: refetchVersion,
|
||||
} = useQuery({
|
||||
queryKey: ["agentVersion"],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const response = await api.get("/agent/version");
|
||||
console.log("🔍 Frontend received version info:", response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch version info:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
refetchInterval: 5 * 60 * 1000, // Refetch every 5 minutes
|
||||
enabled: true, // Always enabled
|
||||
retry: 3, // Retry failed requests
|
||||
});
|
||||
|
||||
const {
|
||||
data: _availableVersions,
|
||||
isLoading: _versionsLoading,
|
||||
error: _versionsError,
|
||||
} = useQuery({
|
||||
queryKey: ["agentVersions"],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const response = await api.get("/agent/versions");
|
||||
console.log("🔍 Frontend received available versions:", response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch available versions:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
enabled: true,
|
||||
retry: 3,
|
||||
});
|
||||
|
||||
const checkUpdatesMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
// First check GitHub for updates
|
||||
await api.post("/agent/version/check");
|
||||
// Then refresh current agent version detection
|
||||
await api.post("/agent/version/refresh");
|
||||
},
|
||||
onSuccess: () => {
|
||||
refetchAgentFile();
|
||||
setShowUploadModal(false);
|
||||
refetchVersion();
|
||||
showToast("Successfully checked for updates", "success");
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Upload agent error:", error);
|
||||
console.error("Check updates error:", error);
|
||||
showToast(`Failed to check for updates: ${error.message}`, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const downloadUpdateMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
// Download the latest binaries
|
||||
const downloadResult = await api.post("/agent/version/download");
|
||||
// Refresh current agent version detection after download
|
||||
await api.post("/agent/version/refresh");
|
||||
// Return the download result for success handling
|
||||
return downloadResult;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
console.log("Download completed:", data);
|
||||
console.log("Download response data:", data.data);
|
||||
refetchVersion();
|
||||
// Show success message
|
||||
const message =
|
||||
data.data?.message || "Agent binaries downloaded successfully";
|
||||
showToast(message, "success");
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Download update error:", error);
|
||||
showToast(`Download failed: ${error.message}`, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const getVersionStatus = () => {
|
||||
console.log("🔍 getVersionStatus called with:", {
|
||||
versionError,
|
||||
versionInfo,
|
||||
versionLoading,
|
||||
});
|
||||
|
||||
if (versionError) {
|
||||
console.log("❌ Version error detected:", versionError);
|
||||
return {
|
||||
status: "error",
|
||||
message: "Failed to load version info",
|
||||
Icon: AlertCircle,
|
||||
color: "text-red-600",
|
||||
};
|
||||
}
|
||||
|
||||
if (!versionInfo || versionLoading) {
|
||||
console.log("⏳ Loading state:", { versionInfo, versionLoading });
|
||||
return {
|
||||
status: "loading",
|
||||
message: "Loading version info...",
|
||||
Icon: RefreshCw,
|
||||
color: "text-gray-600",
|
||||
};
|
||||
}
|
||||
|
||||
// Use the backend's updateStatus for proper semver comparison
|
||||
switch (versionInfo.updateStatus) {
|
||||
case "update-available":
|
||||
return {
|
||||
status: "update-available",
|
||||
message: `Update available: ${versionInfo.latestVersion}`,
|
||||
Icon: Clock,
|
||||
color: "text-yellow-600",
|
||||
};
|
||||
case "newer-version":
|
||||
return {
|
||||
status: "newer-version",
|
||||
message: `Newer version running: ${versionInfo.currentVersion}`,
|
||||
Icon: CheckCircle,
|
||||
color: "text-blue-600",
|
||||
};
|
||||
case "up-to-date":
|
||||
return {
|
||||
status: "up-to-date",
|
||||
message: `Up to date: ${versionInfo.latestVersion}`,
|
||||
Icon: CheckCircle,
|
||||
color: "text-green-600",
|
||||
};
|
||||
case "no-agent":
|
||||
return {
|
||||
status: "no-agent",
|
||||
message: "No agent binary found",
|
||||
Icon: AlertCircle,
|
||||
color: "text-orange-600",
|
||||
};
|
||||
case "github-unavailable":
|
||||
return {
|
||||
status: "github-unavailable",
|
||||
message: `Agent running: ${versionInfo.currentVersion} (GitHub API unavailable)`,
|
||||
Icon: CheckCircle,
|
||||
color: "text-purple-600",
|
||||
};
|
||||
case "no-data":
|
||||
return {
|
||||
status: "no-data",
|
||||
message: "No version data available",
|
||||
Icon: AlertCircle,
|
||||
color: "text-gray-600",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
status: "unknown",
|
||||
message: "Version status unknown",
|
||||
Icon: AlertCircle,
|
||||
color: "text-gray-600",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const versionStatus = getVersionStatus();
|
||||
const StatusIcon = versionStatus.Icon;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<div className="flex items-center mb-2">
|
||||
<Code className="h-6 w-6 text-primary-600 mr-3" />
|
||||
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
Agent File Management
|
||||
</h2>
|
||||
{/* Toast Notification */}
|
||||
{toast && (
|
||||
<div
|
||||
className={`fixed top-4 right-4 z-50 max-w-md rounded-lg shadow-lg border-2 p-4 flex items-start space-x-3 animate-in slide-in-from-top-5 ${
|
||||
toast.type === "success"
|
||||
? "bg-green-50 dark:bg-green-900/90 border-green-500 dark:border-green-600"
|
||||
: "bg-red-50 dark:bg-red-900/90 border-red-500 dark:border-red-600"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex-shrink-0 rounded-full p-1 ${
|
||||
toast.type === "success"
|
||||
? "bg-green-100 dark:bg-green-800"
|
||||
: "bg-red-100 dark:bg-red-800"
|
||||
}`}
|
||||
>
|
||||
{toast.type === "success" ? (
|
||||
<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
) : (
|
||||
<AlertCircle className="h-5 w-5 text-red-600 dark:text-red-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p
|
||||
className={`text-sm font-medium ${
|
||||
toast.type === "success"
|
||||
? "text-green-800 dark:text-green-100"
|
||||
: "text-red-800 dark:text-red-100"
|
||||
}`}
|
||||
>
|
||||
{toast.message}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-secondary-500 dark:text-secondary-300">
|
||||
Manage the PatchMon agent script file used for installations and
|
||||
updates
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const url = "/api/v1/hosts/agent/download";
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "patchmon-agent.sh";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}}
|
||||
className="btn-outline flex items-center gap-2"
|
||||
onClick={() => setToast(null)}
|
||||
className={`flex-shrink-0 rounded-lg p-1 transition-colors ${
|
||||
toast.type === "success"
|
||||
? "hover:bg-green-100 dark:hover:bg-green-800 text-green-600 dark:text-green-400"
|
||||
: "hover:bg-red-100 dark:hover:bg-red-800 text-red-600 dark:text-red-400"
|
||||
}`}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Replace Script
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-bold text-secondary-900 dark:text-white mb-2">
|
||||
Agent Version Management
|
||||
</h2>
|
||||
<p className="text-secondary-600 dark:text-secondary-400">
|
||||
Monitor and manage agent versions across your infrastructure
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{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">
|
||||
<Code className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||
<p className="text-secondary-500 dark:text-secondary-300">
|
||||
No agent script found
|
||||
</p>
|
||||
<p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2">
|
||||
Upload an agent script to get started
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-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">
|
||||
<Code 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-3">
|
||||
To completely remove PatchMon from a host:
|
||||
</p>
|
||||
|
||||
{/* Go Agent Uninstall */}
|
||||
<div className="mb-3">
|
||||
<div className="space-y-2">
|
||||
<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">
|
||||
sudo patchmon-agent uninstall
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
"sudo patchmon-agent uninstall",
|
||||
);
|
||||
}}
|
||||
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>
|
||||
<div className="text-xs text-red-600 dark:text-red-400">
|
||||
Options: <code>--remove-config</code>,{" "}
|
||||
<code>--remove-logs</code>, <code>--remove-all</code>,{" "}
|
||||
<code>--force</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-xs">
|
||||
⚠️ This command will remove all PatchMon files,
|
||||
configuration, and crontab entries
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent Upload Modal */}
|
||||
{showUploadModal && (
|
||||
<AgentUploadModal
|
||||
isOpen={showUploadModal}
|
||||
onClose={() => setShowUploadModal(false)}
|
||||
onSubmit={uploadAgentMutation.mutate}
|
||||
isLoading={uploadAgentMutation.isPending}
|
||||
error={uploadAgentMutation.error}
|
||||
scriptFileId={scriptFileId}
|
||||
scriptContentId={scriptContentId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Agent Upload Modal Component
|
||||
const AgentUploadModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
isLoading,
|
||||
error,
|
||||
scriptFileId,
|
||||
scriptContentId,
|
||||
}) => {
|
||||
const [scriptContent, setScriptContent] = useState("");
|
||||
const [uploadError, setUploadError] = useState("");
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
setUploadError("");
|
||||
|
||||
if (!scriptContent.trim()) {
|
||||
setUploadError("Script content is required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!scriptContent.trim().startsWith("#!/")) {
|
||||
setUploadError(
|
||||
"Script must start with a shebang (#!/bin/bash or #!/bin/sh)",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit(scriptContent);
|
||||
};
|
||||
|
||||
const handleFileUpload = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
setScriptContent(event.target.result);
|
||||
setUploadError("");
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||
Replace Agent Script
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
|
||||
{/* Status Banner */}
|
||||
<div
|
||||
className={`rounded-xl shadow-sm p-6 border-2 ${
|
||||
versionStatus.status === "up-to-date"
|
||||
? "bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800"
|
||||
: versionStatus.status === "update-available"
|
||||
? "bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800"
|
||||
: versionStatus.status === "no-agent"
|
||||
? "bg-orange-50 dark:bg-orange-900/20 border-orange-200 dark:border-orange-800"
|
||||
: "bg-white dark:bg-secondary-800 border-secondary-200 dark:border-secondary-600"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-4">
|
||||
<div
|
||||
className={`p-3 rounded-lg ${
|
||||
versionStatus.status === "up-to-date"
|
||||
? "bg-green-100 dark:bg-green-800"
|
||||
: versionStatus.status === "update-available"
|
||||
? "bg-yellow-100 dark:bg-yellow-800"
|
||||
: versionStatus.status === "no-agent"
|
||||
? "bg-orange-100 dark:bg-orange-800"
|
||||
: "bg-secondary-100 dark:bg-secondary-700"
|
||||
}`}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="px-6 py-4">
|
||||
<div className="space-y-4">
|
||||
{StatusIcon && (
|
||||
<StatusIcon className={`h-6 w-6 ${versionStatus.color}`} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor={scriptFileId}
|
||||
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
|
||||
>
|
||||
Upload Script File
|
||||
</label>
|
||||
<input
|
||||
id={scriptFileId}
|
||||
type="file"
|
||||
accept=".sh"
|
||||
onChange={handleFileUpload}
|
||||
className="block w-full text-sm text-secondary-500 dark:text-secondary-400 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100 dark:file:bg-primary-900 dark:file:text-primary-200"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Select a .sh file to upload, or paste the script content below
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white mb-1">
|
||||
{versionStatus.message}
|
||||
</h3>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
{versionStatus.status === "up-to-date" &&
|
||||
"All agent binaries are current"}
|
||||
{versionStatus.status === "update-available" &&
|
||||
"A newer version is available for download"}
|
||||
{versionStatus.status === "no-agent" &&
|
||||
"Download agent binaries to get started"}
|
||||
{versionStatus.status === "github-unavailable" &&
|
||||
"Cannot check for updates at this time"}
|
||||
{![
|
||||
"up-to-date",
|
||||
"update-available",
|
||||
"no-agent",
|
||||
"github-unavailable",
|
||||
].includes(versionStatus.status) &&
|
||||
"Version information unavailable"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor={scriptContentId}
|
||||
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
|
||||
>
|
||||
Script Content *
|
||||
</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 # PatchMon Agent Script VERSION="1.0.0" # 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 className="flex justify-end gap-3 mt-6">
|
||||
<button type="button" onClick={onClose} className="btn-outline">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !scriptContent.trim()}
|
||||
className="btn-primary"
|
||||
>
|
||||
{isLoading ? "Uploading..." : "Replace Script"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => checkUpdatesMutation.mutate()}
|
||||
disabled={checkUpdatesMutation.isPending}
|
||||
className="flex items-center px-4 py-2 bg-white dark:bg-secondary-700 text-secondary-700 dark:text-secondary-200 rounded-lg hover:bg-secondary-50 dark:hover:bg-secondary-600 border border-secondary-300 dark:border-secondary-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-sm hover:shadow"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 mr-2 ${checkUpdatesMutation.isPending ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{checkUpdatesMutation.isPending
|
||||
? "Checking..."
|
||||
: "Check for Updates"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Version Information Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Current Version Card */}
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-xl shadow-sm p-6 border border-secondary-200 dark:border-secondary-600 hover:shadow-md transition-shadow duration-200">
|
||||
<h4 className="text-sm font-medium text-secondary-500 dark:text-secondary-400 mb-2">
|
||||
Current Version
|
||||
</h4>
|
||||
<p className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||
{versionInfo?.currentVersion || (
|
||||
<span className="text-lg text-secondary-400 dark:text-secondary-500">
|
||||
Not detected
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Latest Version Card */}
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-xl shadow-sm p-6 border border-secondary-200 dark:border-secondary-600 hover:shadow-md transition-shadow duration-200">
|
||||
<h4 className="text-sm font-medium text-secondary-500 dark:text-secondary-400 mb-2">
|
||||
Latest Available
|
||||
</h4>
|
||||
<p className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||
{versionInfo?.latestVersion || (
|
||||
<span className="text-lg text-secondary-400 dark:text-secondary-500">
|
||||
Unknown
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Last Checked Card */}
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-xl shadow-sm p-6 border border-secondary-200 dark:border-secondary-600 hover:shadow-md transition-shadow duration-200">
|
||||
<h4 className="text-sm font-medium text-secondary-500 dark:text-secondary-400 mb-2">
|
||||
Last Checked
|
||||
</h4>
|
||||
<p className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
{versionInfo?.lastChecked
|
||||
? new Date(versionInfo.lastChecked).toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: "Never"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Download Updates Section */}
|
||||
<div className="bg-gradient-to-br from-primary-50 to-blue-50 dark:from-secondary-800 dark:to-secondary-800 rounded-xl shadow-sm p-8 border border-primary-200 dark:border-secondary-600">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-bold text-secondary-900 dark:text-white mb-3">
|
||||
{!versionInfo?.currentVersion
|
||||
? "Get Started with Agent Binaries"
|
||||
: versionStatus.status === "update-available"
|
||||
? "New Agent Version Available"
|
||||
: "Agent Binaries"}
|
||||
</h3>
|
||||
<p className="text-secondary-700 dark:text-secondary-300 mb-4">
|
||||
{!versionInfo?.currentVersion
|
||||
? "No agent binaries detected. Download from GitHub to begin managing your agents."
|
||||
: versionStatus.status === "update-available"
|
||||
? `A new agent version (${versionInfo.latestVersion}) is available. Download the latest binaries from GitHub.`
|
||||
: "Download or redownload agent binaries from GitHub."}
|
||||
</p>
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => downloadUpdateMutation.mutate()}
|
||||
disabled={downloadUpdateMutation.isPending}
|
||||
className="flex items-center px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-md hover:shadow-lg font-medium"
|
||||
>
|
||||
{downloadUpdateMutation.isPending ? (
|
||||
<>
|
||||
<RefreshCw className="h-5 w-5 mr-2 animate-spin" />
|
||||
Downloading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-5 w-5 mr-2" />
|
||||
{!versionInfo?.currentVersion
|
||||
? "Download Binaries"
|
||||
: versionStatus.status === "update-available"
|
||||
? "Download New Agent Version"
|
||||
: "Redownload Binaries"}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<a
|
||||
href="https://github.com/PatchMon/PatchMon-agent/releases"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center px-4 py-3 text-secondary-700 dark:text-secondary-300 hover:text-primary-600 dark:hover:text-primary-400 transition-colors duration-200 font-medium"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
View on GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Supported Architectures */}
|
||||
{versionInfo?.supportedArchitectures &&
|
||||
versionInfo.supportedArchitectures.length > 0 && (
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-xl shadow-sm p-6 border border-secondary-200 dark:border-secondary-600">
|
||||
<h4 className="text-lg font-semibold text-secondary-900 dark:text-white mb-4">
|
||||
Supported Architectures
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{versionInfo.supportedArchitectures.map((arch) => (
|
||||
<div
|
||||
key={arch}
|
||||
className="flex items-center justify-center px-4 py-3 bg-secondary-50 dark:bg-secondary-700 rounded-lg border border-secondary-200 dark:border-secondary-600"
|
||||
>
|
||||
<code className="text-sm font-mono text-secondary-700 dark:text-secondary-300">
|
||||
{arch}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -228,7 +228,33 @@ const Automation = () => {
|
||||
// Use the proxied URL through the frontend (port 3000)
|
||||
// This avoids CORS issues as everything goes through the same origin
|
||||
const url = `/bullboard?token=${encodeURIComponent(token)}`;
|
||||
window.open(url, "_blank", "width=1200,height=800");
|
||||
// Open in a new tab instead of a new window
|
||||
const bullBoardWindow = window.open(url, "_blank");
|
||||
|
||||
// Add a message listener to handle authentication failures
|
||||
if (bullBoardWindow) {
|
||||
// Listen for authentication failures and refresh with token
|
||||
const checkAuth = () => {
|
||||
try {
|
||||
// Check if the Bull Board window is still open
|
||||
if (bullBoardWindow.closed) return;
|
||||
|
||||
// Inject a script to handle authentication failures
|
||||
bullBoardWindow.postMessage(
|
||||
{
|
||||
type: "BULL_BOARD_TOKEN",
|
||||
token: token,
|
||||
},
|
||||
window.location.origin,
|
||||
);
|
||||
} catch (e) {
|
||||
console.log("Could not communicate with Bull Board window:", e);
|
||||
}
|
||||
};
|
||||
|
||||
// Send token after a short delay to ensure Bull Board is loaded
|
||||
setTimeout(checkAuth, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
const triggerManualJob = async (jobType, data = {}) => {
|
||||
|
||||
1
package-lock.json
generated
1
package-lock.json
generated
@@ -29,6 +29,7 @@
|
||||
"@bull-board/api": "^6.13.1",
|
||||
"@bull-board/express": "^6.13.1",
|
||||
"@prisma/client": "^6.1.0",
|
||||
"axios": "^1.7.9",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bullmq": "^5.61.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
|
||||
Reference in New Issue
Block a user