2 * @file agentManifest.js
3 * @module routes/agentManifest
4 * @description Agent distribution manifest and file download endpoints for auto-updates.
5 * Generates SHA-256 file manifests for agent version distribution and serves downloadable files.
6 * Supports plugin auto-sync and agent executable updates.
11 * @author RMM-PSA Development Team
12 * @copyright 2026 RMM-PSA Platform
13 * @license Proprietary
17 * @apiDefine AgentManifest Agent Distribution
18 * Agent version manifest and file downloads for auto-updates
21const express = require('express');
22const router = express.Router();
23const fs = require('fs');
24const path = require('path');
25const crypto = require('crypto');
27const DIST_PATH = '/opt/apps/IBG_HUB_dist/agent';
30 * @api {get} /api/agent/manifest Get agent file manifest
31 * @apiName GetAgentManifest
32 * @apiGroup AgentManifest
33 * @apiDescription Retrieve file manifest for latest agent version. Returns SHA-256 hashes,
34 * file sizes, and modification times for all files in agent distribution (plugins, executables).
35 * Used by agents to detect changes and sync files. Manifest is cached and regenerated on demand.
36 * @apiSuccess {string} version Latest agent version (e.g., "1.0.5")
37 * @apiSuccess {DateTime} generated Manifest generation timestamp
38 * @apiSuccess {object} files File manifest dictionary (path → metadata)
39 * @apiSuccess {string} files.path File relative path (key)
40 * @apiSuccess {object} files.metadata File metadata (value)
41 * @apiSuccess {string} files.metadata.hash SHA-256 hash of file content
42 * @apiSuccess {number} files.metadata.size File size in bytes
43 * @apiSuccess {DateTime} files.metadata.modified Last modified timestamp
44 * @apiError (404) {String} error="No version available" latest_version.txt not found
45 * @apiError (404) {String} error="Version directory not found" Version folder missing
46 * @apiError (500) {String} error Error message from manifest generation
47 * @apiExample {curl} Example:
48 * curl -X GET http://localhost:3000/api/agent/manifest
49 * @apiSuccessExample {json} Success-Response:
53 * "generated": "2026-03-12T10:00:00.000Z",
56 * "hash": "a3f2c1b...",
58 * "modified": "2026-03-10T08:00:00.000Z"
60 * "plugins/rustdesk.js": {
61 * "hash": "b4e3d2c...",
63 * "modified": "2026-03-11T12:30:00.000Z"
68router.get('/manifest', async (req, res) => {
70 console.log('[Manifest] GET /api/agent/manifest called');
72 const versionFile = path.join(DIST_PATH, 'latest_version.txt');
73 if (!fs.existsSync(versionFile)) {
74 console.error('[Manifest] latest_version.txt not found');
75 return res.status(404).json({ error: 'No version available' });
78 const version = fs.readFileSync(versionFile, 'utf8').trim();
79 const versionDir = path.join(DIST_PATH, version);
81 if (!fs.existsSync(versionDir)) {
82 return res.status(404).json({ error: 'Version directory not found' });
85 // Check for cached manifest
86 const manifestPath = path.join(versionDir, 'file-manifest.json');
87 if (fs.existsSync(manifestPath)) {
88 const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
89 return res.json(manifest);
92 // Generate manifest on the fly if not cached
93 console.log('[Manifest] Generating manifest for version:', version);
94 const manifest = await generateManifest(versionDir, version);
97 fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
101 console.error('[Manifest] Error:', err);
102 res.status(500).json({ error: err.message });
107 * @api {get} /api/agent/file Download agent file
108 * @apiName DownloadAgentFile
109 * @apiGroup AgentManifest
110 * @apiDescription Download specific file from agent distribution directory.
111 * File path provided via query parameter. Includes directory traversal protection
112 * and real path validation to prevent unauthorized file access. Used by agents
113 * to download updated plugins and executables during auto-update process.
114 * @apiParam {string} path Relative file path (query parameter, e.g., "plugins/rustdesk.js")
115 * @apiSuccess {File} file Binary file download with attachment disposition
116 * @apiError (400) {String} error="Invalid file path" Path contains ".." or starts with "/"
117 * @apiError (404) {String} error="No version available" latest_version.txt not found
118 * @apiError (404) {String} error="File not found" Requested file does not exist
119 * @apiError (403) {String} error="Access denied" File path outside version directory (security)
120 * @apiError (500) {String} error Error message from file system operations
121 * @apiExample {curl} Example:
122 * curl -X GET "http://localhost:3000/api/agent/file?path=plugins/rustdesk.js" \\
124 * @apiSuccessExample {binary} Success-Response:
126 * Content-Type: application/javascript
127 * Content-Disposition: attachment; filename="rustdesk.js"
129 * [binary file content]
131router.get('/file', async (req, res) => {
133 // Get path from query parameter
134 const relativePath = decodeURIComponent(req.query.path || '');
136 // Security: prevent directory traversal
137 if (relativePath.includes('..') || relativePath.startsWith('/')) {
138 return res.status(400).json({ error: 'Invalid file path' });
141 // Get latest version
142 const versionFile = path.join(DIST_PATH, 'latest_version.txt');
143 if (!fs.existsSync(versionFile)) {
144 return res.status(404).json({ error: 'No version available' });
147 const version = fs.readFileSync(versionFile, 'utf8').trim();
148 const filePath = path.join(DIST_PATH, version, relativePath);
150 if (!fs.existsSync(filePath)) {
151 return res.status(404).json({ error: 'File not found' });
154 // Security: ensure file is within version directory
155 const realPath = fs.realpathSync(filePath);
156 const realVersionDir = fs.realpathSync(path.join(DIST_PATH, version));
157 if (!realPath.startsWith(realVersionDir)) {
158 return res.status(403).json({ error: 'Access denied' });
162 const filename = path.basename(filePath);
163 res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
164 res.sendFile(realPath);
166 console.error('[Manifest] File download error:', err);
167 res.status(500).json({ error: err.message });
172 * Generate manifest by scanning directory
176async function generateManifest(versionDir, version) {
179 generated: new Date().toISOString(),
183 // Files/directories to include in manifest
184 const includePaths = [
185 'plugins', // All plugin files
186 // Add other directories as needed
189 // Include specific files
190 const includeFiles = [
191 'EverydayTechTray.exe', // Tray icon executable
192 'agent.exe' // Main agent executable
195 for (const includePath of includePaths) {
196 const fullPath = path.join(versionDir, includePath);
197 if (fs.existsSync(fullPath)) {
198 scanDirectory(fullPath, versionDir, manifest.files);
202 // Add specific files to manifest
203 for (const includeFile of includeFiles) {
204 const fullPath = path.join(versionDir, includeFile);
205 if (fs.existsSync(fullPath)) {
206 const stats = fs.statSync(fullPath);
207 const hash = getFileHash(fullPath);
209 manifest.files[includeFile] = {
212 modified: stats.mtime.toISOString()
221 * Recursively scan directory and add files to manifest
226function scanDirectory(dirPath, basePath, files) {
227 const entries = fs.readdirSync(dirPath, { withFileTypes: true });
229 for (const entry of entries) {
230 const fullPath = path.join(dirPath, entry.name);
232 if (entry.isDirectory()) {
233 scanDirectory(fullPath, basePath, files);
234 } else if (entry.isFile()) {
235 const relativePath = path.relative(basePath, fullPath).replace(/\\/g, '/');
236 const stats = fs.statSync(fullPath);
237 const hash = getFileHash(fullPath);
239 files[relativePath] = {
242 modified: stats.mtime.toISOString()
249 * Calculate SHA-256 hash of file
252function getFileHash(filePath) {
253 const hash = crypto.createHash('sha256');
254 const data = fs.readFileSync(filePath);
256 return hash.digest('hex');
259module.exports = router;