diff --git a/agents/patchmon-agent-linux-386 b/agents/patchmon-agent-linux-386 index f0d6ea4..2568ee1 100755 Binary files a/agents/patchmon-agent-linux-386 and b/agents/patchmon-agent-linux-386 differ diff --git a/agents/patchmon-agent-linux-amd64 b/agents/patchmon-agent-linux-amd64 index 8d1a994..34c12df 100755 Binary files a/agents/patchmon-agent-linux-amd64 and b/agents/patchmon-agent-linux-amd64 differ diff --git a/agents/patchmon-agent-linux-arm b/agents/patchmon-agent-linux-arm index 70d723c..e1b51e6 100755 Binary files a/agents/patchmon-agent-linux-arm and b/agents/patchmon-agent-linux-arm differ diff --git a/agents/patchmon-agent-linux-arm64 b/agents/patchmon-agent-linux-arm64 index 0a1803a..1a71e58 100755 Binary files a/agents/patchmon-agent-linux-arm64 and b/agents/patchmon-agent-linux-arm64 differ diff --git a/backend/src/routes/agentVersionRoutes.js b/backend/src/routes/agentVersionRoutes.js new file mode 100644 index 0000000..20d8c2b --- /dev/null +++ b/backend/src/routes/agentVersionRoutes.js @@ -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; diff --git a/backend/src/server.js b/backend/src/server.js index 2703238..bf10591 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -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) { @@ -440,6 +442,7 @@ 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; @@ -892,6 +895,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") { diff --git a/backend/src/services/agentVersionService.js b/backend/src/services/agentVersionService.js new file mode 100644 index 0000000..c8f3bf9 --- /dev/null +++ b/backend/src/services/agentVersionService.js @@ -0,0 +1,684 @@ +const axios = require("axios"); +const fs = require("node:fs").promises; +const path = require("node:path"); +const { exec } = 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 { stdout, stderr } = await execAsync(`${agentPath} --help`, { + timeout: 10000, + }); + + if (stderr) { + console.log("â ī¸ Agent help stderr:", stderr); + } + + // Parse version from help output (e.g., "PatchMon Agent v1.3.0") + const versionMatch = 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:", + 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"; + + if (this.currentVersion && this.latestVersion) { + const comparison = compareVersions( + this.currentVersion, + this.latestVersion, + ); + 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 (this.latestVersion && !this.currentVersion) { + hasUpdate = true; + updateStatus = "no-agent"; + } else if (this.currentVersion && !this.latestVersion) { + // We have a current version but no latest version (GitHub API unavailable) + hasUpdate = false; + updateStatus = "github-unavailable"; + } else if (!this.currentVersion && !this.latestVersion) { + updateStatus = "no-data"; + } + + return { + currentVersion: this.currentVersion, + latestVersion: this.latestVersion, + hasUpdate: hasUpdate, + updateStatus: updateStatus, + lastChecked: this.lastChecked, + supportedArchitectures: this.supportedArchitectures, + status: this.latestVersion ? "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 { + 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(); diff --git a/backend/src/services/agentWs.js b/backend/src/services/agentWs.js index b66adba..3d34f35 100644 --- a/backend/src/services/agentWs.js +++ b/backend/src/services/agentWs.js @@ -132,6 +132,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 +230,8 @@ module.exports = { broadcastSettingsUpdate, pushReportNow, pushSettingsUpdate, + pushUpdateNotification, + pushUpdateNotificationToAll, // Expose read-only view of connected agents getConnectedApiIds: () => Array.from(apiIdToSocket.keys()), isConnected: (apiId) => { diff --git a/frontend/src/components/settings/AgentManagementTab.jsx b/frontend/src/components/settings/AgentManagementTab.jsx index 203317c..fccc09c 100644 --- a/frontend/src/components/settings/AgentManagementTab.jsx +++ b/frontend/src/components/settings/AgentManagementTab.jsx @@ -1,387 +1,282 @@ -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, RefreshCw } from "lucide-react"; +import api from "../../utils/api"; const AgentManagementTab = () => { - const scriptFileId = useId(); - const scriptContentId = useId(); - const [showUploadModal, setShowUploadModal] = useState(false); + const _queryClient = useQueryClient(); - // Agent file queries and mutations + // Agent version queries const { - data: agentFileInfo, - isLoading: agentFileLoading, - error: agentFileError, - refetch: refetchAgentFile, + data: versionInfo, + isLoading: versionLoading, + error: versionError, + refetch: refetchVersion, } = useQuery({ - queryKey: ["agentFile"], - queryFn: () => agentFileAPI.getInfo().then((res) => res.data), + 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 }); - // Fetch settings for dynamic curl flags - const { data: settings } = useQuery({ - queryKey: ["settings"], - queryFn: () => settingsAPI.get().then((res) => res.data), + 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, }); - // Helper function to get curl flags based on settings - const _getCurlFlags = () => { - return settings?.ignore_ssl_self_signed ? "-sk" : "-s"; - }; - - const uploadAgentMutation = useMutation({ - mutationFn: (scriptContent) => - agentFileAPI.upload(scriptContent).then((res) => res.data), + 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(); }, onError: (error) => { - console.error("Upload agent error:", error); + console.error("Check updates error:", 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"; + alert(`â ${message}`); + }, + onError: (error) => { + console.error("Download update error:", error); + alert(`â Download failed: ${error.message}`); + }, + }); + + 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 (
- - Manage the PatchMon agent script file used for installations and - updates +
+ Monitor agent versions and download updates
- Error loading agent file: {agentFileError.message} -
-
- - No agent script found -
-- Upload an agent script to get started -
-
-
- Version:
-
-
- {agentFileInfo.version}
-
-
-
- Modified:
-
-
- {new Date(agentFileInfo.lastModified).toLocaleDateString()}
-
- This script is used for:
-- To completely remove PatchMon from a host: -
- - {/* Go Agent Uninstall */} ---remove-config
,{" "}
- --remove-logs
, --remove-all
,{" "}
- --force
- - â ī¸ This command will remove all PatchMon files, - configuration, and crontab entries -
-+ {versionInfo?.currentVersion + ? "Download the latest agent binaries from GitHub" + : "No agent binaries found. Download from GitHub to get started."} +