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"; // Latest version should ALWAYS come from GitHub, not from local binaries // currentVersion = what's installed locally // latestVersion = what's available on GitHub if (this.latestVersion) { console.log(`đŸ“Ļ Latest version from GitHub: ${this.latestVersion}`); } else { console.log( `âš ī¸ No GitHub release version available (API may be unavailable)`, ); } if (this.currentVersion) { console.log(`💾 Current local agent version: ${this.currentVersion}`); } else { console.log(`âš ī¸ No local agent binary found`); } // Determine update status by comparing current vs latest (from GitHub) 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, // Always return GitHub version, not local 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 { // 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();