diff --git a/server/configApi.js b/server/configApi.js index 381bdbbda..dc4a90ddc 100644 --- a/server/configApi.js +++ b/server/configApi.js @@ -48,6 +48,7 @@ class ConfigApi { advanced: { metricInterval: config.PULSE_METRIC_INTERVAL_MS, discoveryInterval: config.PULSE_DISCOVERY_INTERVAL_MS, + updateChannel: config.UPDATE_CHANNEL || 'stable', alerts: { cpu: { enabled: config.ALERT_CPU_ENABLED !== 'false', @@ -271,6 +272,14 @@ class ConfigApi { // Update existing config with new values Object.entries(config).forEach(([key, value]) => { if (value !== undefined && value !== '') { + // Special validation for UPDATE_CHANNEL + if (key === 'UPDATE_CHANNEL') { + const validChannels = ['stable', 'rc']; + if (!validChannels.includes(value)) { + console.warn(`WARN: Invalid UPDATE_CHANNEL value "${value}" in config. Skipping.`); + return; // Skip this invalid value + } + } existingConfig[key] = value; } }); @@ -595,8 +604,8 @@ class ConfigApi { if (keys.length > 0 && keys.some(key => config[key])) { lines.push(`# ${groupName}`); keys.forEach(key => { - if (config[key] !== undefined && config[key] !== '') { - const value = config[key]; + if (config[key] !== undefined && config[key] !== '' && config[key] !== null) { + const value = String(config[key]); // Ensure value is a string const needsQuotes = value.includes(' ') || value.includes('#') || value.includes('='); lines.push(`${key}=${needsQuotes ? `"${value}"` : value}`); } @@ -621,8 +630,8 @@ class ConfigApi { `PROXMOX_ALLOW_SELF_SIGNED_CERTS_${index}` ]; orderedKeys.forEach(key => { - if (config[key] !== undefined && config[key] !== '') { - const value = config[key]; + if (config[key] !== undefined && config[key] !== '' && config[key] !== null) { + const value = String(config[key]); // Ensure value is a string const needsQuotes = value.includes(' ') || value.includes('#') || value.includes('='); lines.push(`${key}=${needsQuotes ? `"${value}"` : value}`); } @@ -646,8 +655,8 @@ class ConfigApi { `PBS_ALLOW_SELF_SIGNED_CERTS_${index}` ]; orderedKeys.forEach(key => { - if (config[key] !== undefined && config[key] !== '') { - const value = config[key]; + if (config[key] !== undefined && config[key] !== '' && config[key] !== null) { + const value = String(config[key]); // Ensure value is a string const needsQuotes = value.includes(' ') || value.includes('#') || value.includes('='); lines.push(`${key}=${needsQuotes ? `"${value}"` : value}`); } @@ -662,8 +671,8 @@ class ConfigApi { if (keys.length > 0 && keys.some(key => config[key])) { lines.push(`# ${groupName}`); keys.forEach(key => { - if (config[key] !== undefined && config[key] !== '') { - const value = config[key]; + if (config[key] !== undefined && config[key] !== '' && config[key] !== null) { + const value = String(config[key]); // Ensure value is a string const needsQuotes = value.includes(' ') || value.includes('#') || value.includes('='); lines.push(`${key}=${needsQuotes ? `"${value}"` : value}`); } diff --git a/server/configLoader.js b/server/configLoader.js index 8b63bba09..634d15c0a 100644 --- a/server/configLoader.js +++ b/server/configLoader.js @@ -32,6 +32,19 @@ class ConfigurationError extends Error { } } +// Function to get update channel preference +function getUpdateChannelPreference() { + const updateChannel = process.env.UPDATE_CHANNEL || 'stable'; + const validChannels = ['stable', 'rc']; + + if (!validChannels.includes(updateChannel)) { + console.warn(`WARN: Invalid UPDATE_CHANNEL value "${updateChannel}". Using default "stable".`); + return 'stable'; + } + + return updateChannel; +} + // Function to load PBS configuration function loadPbsConfig(index = null) { const suffix = index ? `_${index}` : ''; @@ -278,8 +291,12 @@ function loadConfiguration() { } // console.log('INFO: Configuration loaded successfully.'); + + // Load update channel preference + const updateChannel = getUpdateChannelPreference(); + // Return the flag along with endpoints and pbsConfigs - return { endpoints, pbsConfigs, isConfigPlaceholder }; + return { endpoints, pbsConfigs, isConfigPlaceholder, updateChannel }; } -module.exports = { loadConfiguration, ConfigurationError }; // Export the function and error class +module.exports = { loadConfiguration, getUpdateChannelPreference, ConfigurationError }; // Export the function and error class diff --git a/server/updateManager.js b/server/updateManager.js index b9ca9211b..86d725c4d 100644 --- a/server/updateManager.js +++ b/server/updateManager.js @@ -4,6 +4,7 @@ const fs = require('fs').promises; const path = require('path'); const { exec } = require('child_process'); const { promisify } = require('util'); +const { getUpdateChannelPreference } = require('./configLoader'); const execAsync = promisify(exec); class UpdateManager { @@ -13,6 +14,47 @@ class UpdateManager { this.updateInProgress = false; } + /** + * Check if current version is a release candidate + */ + isReleaseCandidate(version) { + // Only use currentVersion as default if no argument is passed at all + const versionToCheck = (arguments.length === 0) ? this.currentVersion : version; + + if (!versionToCheck || typeof versionToCheck !== 'string') { + return false; + } + return versionToCheck.includes('-rc') || versionToCheck.includes('-alpha') || versionToCheck.includes('-beta'); + } + + /** + * Validate download URL for security + */ + isValidDownloadUrl(downloadUrl) { + if (!downloadUrl || typeof downloadUrl !== 'string') { + return false; + } + + try { + const url = new URL(downloadUrl); + + // Allow test mode URLs + if (process.env.UPDATE_TEST_MODE === 'true' && + url.hostname === 'localhost' && + url.pathname.includes('/api/test/mock-update.tar.gz')) { + return true; + } + + // Only allow HTTPS GitHub release asset URLs + return url.protocol === 'https:' && + url.hostname === 'github.com' && + url.pathname.includes('/releases/download/') && + url.pathname.includes(`/${this.githubRepo}/`); + } catch (error) { + return false; + } + } + /** * Check for available updates */ @@ -20,17 +62,72 @@ class UpdateManager { try { console.log('[UpdateManager] Checking for updates...'); - // Fetch latest release from GitHub - const response = await axios.get( - `https://api.github.com/repos/${this.githubRepo}/releases/latest`, - { - headers: { - 'Accept': 'application/vnd.github.v3+json', - 'User-Agent': 'Pulse-Update-Checker' - }, - timeout: 10000 + const updateChannel = getUpdateChannelPreference(); + let response; + let channelDescription = ''; + + if (updateChannel === 'stable') { + // Stable channel: only check latest stable release + channelDescription = 'stable releases only'; + console.log('[UpdateManager] Checking for stable releases...'); + response = await axios.get( + `https://api.github.com/repos/${this.githubRepo}/releases/latest`, + { + headers: { + 'Accept': 'application/vnd.github.v3+json', + 'User-Agent': 'Pulse-Update-Checker' + }, + timeout: 10000 + } + ); + } else { + // RC channel: check all releases for RC versions + channelDescription = 'RC releases only'; + console.log('[UpdateManager] Checking for RC releases...'); + response = await axios.get( + `https://api.github.com/repos/${this.githubRepo}/releases?per_page=10`, + { + headers: { + 'Accept': 'application/vnd.github.v3+json', + 'User-Agent': 'Pulse-Update-Checker' + }, + timeout: 10000 + } + ); + + // Find the latest RC release that's newer than current + let latestRelease = null; + const releases = response.data; + + for (const release of releases) { + const releaseVersion = release.tag_name.replace('v', ''); + const releaseIsRC = this.isReleaseCandidate(releaseVersion); + + if (releaseIsRC && semver.gt(releaseVersion, this.currentVersion)) { + latestRelease = release; + break; + } } - ); + + if (!latestRelease) { + // No newer RC version found + const updateInfo = { + currentVersion: this.currentVersion, + latestVersion: this.currentVersion, + updateAvailable: false, + isDocker: this.isDockerEnvironment(), + releaseNotes: 'No newer RC version available', + releaseUrl: null, + publishedAt: null, + assets: [], + updateChannel: channelDescription + }; + console.log(`[UpdateManager] No RC updates available: ${this.currentVersion}`); + return updateInfo; + } + + response.data = latestRelease; + } const latestVersion = response.data.tag_name.replace('v', ''); const updateAvailable = semver.gt(latestVersion, this.currentVersion); @@ -43,6 +140,7 @@ class UpdateManager { releaseNotes: response.data.body || 'No release notes available', releaseUrl: response.data.html_url, publishedAt: response.data.published_at, + updateChannel: channelDescription, assets: response.data.assets.map(asset => ({ name: asset.name, size: asset.size, @@ -50,7 +148,7 @@ class UpdateManager { })) }; - console.log(`[UpdateManager] Current version: ${this.currentVersion}, Latest version: ${latestVersion}, Docker: ${updateInfo.isDocker}`); + console.log(`[UpdateManager] Current version: ${this.currentVersion}, Latest version: ${latestVersion}, Channel: ${channelDescription}, Docker: ${updateInfo.isDocker}`); return updateInfo; } catch (error) { @@ -64,6 +162,11 @@ class UpdateManager { */ async downloadUpdate(downloadUrl, progressCallback) { try { + // Validate download URL for security + if (!this.isValidDownloadUrl(downloadUrl)) { + throw new Error('Invalid download URL. Only GitHub release assets are allowed.'); + } + console.log('[UpdateManager] Downloading update from:', downloadUrl); const tempDir = path.join(__dirname, '..', 'temp'); diff --git a/src/public/js/ui/settings.js b/src/public/js/ui/settings.js index 1f2fa202e..6e7374d03 100644 --- a/src/public/js/ui/settings.js +++ b/src/public/js/ui/settings.js @@ -183,6 +183,13 @@ PulseApp.ui.settings = (() => { } else if (activeTab === 'system') { // Auto-check for latest version when system tab is opened checkLatestVersion(); + // Initialize update channel warning visibility + setTimeout(() => { + const channelSelect = document.querySelector('select[name="UPDATE_CHANNEL"]'); + if (channelSelect) { + onUpdateChannelChange(channelSelect.value); + } + }, 0); } else if (activeTab === 'alerts') { // Load threshold configurations when alerts tab is opened loadThresholdConfigurations(); @@ -716,6 +723,45 @@ PulseApp.ui.settings = (() => {
+ Choose which types of updates to receive +
+ +Latest Version: Checking...
+