diff --git a/README.md b/README.md index 822a367ba..dae40acee 100644 --- a/README.md +++ b/README.md @@ -221,18 +221,25 @@ pulse config import -i backup.enc ## Updates +Pulse shows when updates are available and provides deployment-specific instructions: + +### ProxmoxVE LXC Container +Type `update` in the LXC console - the script handles everything automatically + ### Docker ```bash docker pull rcourtman/pulse:latest docker stop pulse docker rm pulse -# Run docker run command again +# Run docker run command again with your settings ``` ### Manual Install -Settings → System → Check for Updates +```bash +curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | sudo bash +``` -After updates complete, refresh your browser (Ctrl+F5 or Cmd+Shift+R) to load the new version. +The UI will detect your deployment type and show the appropriate update method when a new version is available. ## API diff --git a/docs/API.md b/docs/API.md index fdb509089..0939ccce0 100644 --- a/docs/API.md +++ b/docs/API.md @@ -368,21 +368,30 @@ curl -X POST http://localhost:7655/api/auto-register \ ## Updates ### Check for Updates -Check if a new version is available. +Check if a new version is available. Returns version info and deployment-specific update instructions. ```bash GET /api/updates/check ``` -### Apply Update -Download and apply an available update. +Response includes `deploymentType` field indicating how to update: +- `proxmoxve`: Type `update` in LXC console +- `docker`: Pull new image and recreate container +- `systemd`: Re-run install script +- `manual`: Re-run install script + +### Apply Update (Deprecated) +**⚠️ DEPRECATED**: This endpoint exists for backwards compatibility but is no longer used. +Updates cannot be performed through the API due to security constraints (no sudo access, +containers can't restart themselves). Use deployment-specific update methods instead. ```bash POST /api/updates/apply ``` -### Update Status -Get current update operation status. +### Update Status (Deprecated) +**⚠️ DEPRECATED**: Since updates are no longer performed through the API, this endpoint +is not used by the UI. ```bash GET /api/updates/status diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 3ebb2eb0a..f32d89796 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -60,7 +60,7 @@ Most settings are configured through the web interface at `http://:7655/ - **Nodes**: Auto-discovery, one-click setup scripts, cluster detection - **Alerts**: Thresholds and notification rules -- **Updates**: Update channels and auto-update settings +- **Updates**: Update channels and deployment-specific update instructions - **Security**: Export/import encrypted configurations ## Understanding .env vs .enc Files @@ -113,9 +113,6 @@ Variables that ALWAYS override UI settings: - `PULSE_AUTH_USER` - Username for web UI authentication (overrides UI) - `PULSE_AUTH_PASS` - Bcrypt password hash - MUST be 60 chars in single quotes! (overrides UI) - `UPDATE_CHANNEL` - stable or rc (overrides UI) -- `AUTO_UPDATE_ENABLED` - true/false (overrides UI) -- `AUTO_UPDATE_CHECK_INTERVAL` - Hours between checks (overrides UI) -- `AUTO_UPDATE_TIME` - Update time HH:MM (overrides UI) - `CONNECTION_TIMEOUT` - Connection timeout in seconds (overrides UI) - `ALLOWED_ORIGINS` - CORS origins (overrides UI, default: empty = same-origin only) - `LOG_LEVEL` - debug/info/warn/error (overrides UI) @@ -287,6 +284,33 @@ proxmox-backup-manager acl update / Admin --auth-id pulse-monitor@pbs proxmox-backup-manager user generate-token pulse-monitor@pbs pulse-token ``` +## Updates + +Pulse automatically detects your deployment type and shows appropriate update instructions when a new version is available: + +### ProxmoxVE LXC Containers +- Type `update` in the LXC console +- The community script handles everything automatically +- No manual intervention required + +### Docker +- Pull the latest image: `docker pull rcourtman/pulse:latest` +- Recreate the container with your existing settings +- Data persists in the volume + +### Manual/Systemd Installations +- Re-run the installation script: `curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | sudo bash` +- The script detects existing installations and updates them +- Configuration is preserved + +### Why No In-App Updates? +Pulse cannot update itself from the UI due to security constraints: +- **ProxmoxVE**: The pulse user has no sudo access (security best practice) +- **Docker**: Containers cannot restart themselves +- **Systemd**: Service cannot restart itself without privileges + +This design ensures better security by requiring administrative access for updates. + ## Reverse Proxy Configuration Pulse requires WebSocket support for real-time updates. If using a reverse proxy (nginx, Apache, Caddy, etc.), you **MUST** enable WebSocket proxying. diff --git a/docs/DOCKER.md b/docs/DOCKER.md index a105638fd..6b3eee979 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -105,7 +105,6 @@ volumes: | `CONNECTION_TIMEOUT` | Connection timeout in seconds | `10` | | `LOG_LEVEL` | Logging level | `info` | | `UPDATE_CHANNEL` | Update channel (stable/rc) | `stable` | -| `AUTO_UPDATE_ENABLED` | Enable auto-updates | `false` | ## Volume Management diff --git a/frontend-modern/src/api/updates.ts b/frontend-modern/src/api/updates.ts index 2b234687f..de314d131 100644 --- a/frontend-modern/src/api/updates.ts +++ b/frontend-modern/src/api/updates.ts @@ -26,6 +26,7 @@ export interface VersionInfo { channel?: string; isDocker: boolean; isDevelopment: boolean; + deploymentType?: string; } export class UpdatesAPI { diff --git a/frontend-modern/src/components/Settings/QuickSecuritySetup.tsx b/frontend-modern/src/components/Settings/QuickSecuritySetup.tsx index 96d3dcad1..01f86f4fe 100644 --- a/frontend-modern/src/components/Settings/QuickSecuritySetup.tsx +++ b/frontend-modern/src/components/Settings/QuickSecuritySetup.tsx @@ -8,13 +8,15 @@ interface SecurityCredentials { apiToken?: string; } -export const QuickSecuritySetup: Component = () => { +interface QuickSecuritySetupProps { + onConfigured?: () => void; +} + +export const QuickSecuritySetup: Component = (props) => { const [isSettingUp, setIsSettingUp] = createSignal(false); const [credentials, setCredentials] = createSignal(null); const [showCredentials, setShowCredentials] = createSignal(false); const [copied, setCopied] = createSignal<'username' | 'password' | 'token' | null>(null); - const [readyToRestart, setReadyToRestart] = createSignal(false); - const [isRestarting, setIsRestarting] = createSignal(false); const [useCustomPassword, setUseCustomPassword] = createSignal(false); const [customUsername, setCustomUsername] = createSignal('admin'); const [customPassword, setCustomPassword] = createSignal(''); @@ -48,33 +50,6 @@ export const QuickSecuritySetup: Component = () => { } }; - const restartPulse = async () => { - setIsRestarting(true); - try { - const response = await fetch('/api/security/apply-restart', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'include' // Include cookies for CSRF token - }); - - if (!response.ok) { - throw new Error('Failed to restart Pulse'); - } - - showSuccess('Restarting Pulse... You will be redirected to login.'); - - // Wait for restart then redirect to login - setTimeout(() => { - // Just reload - the auth check in App.tsx will show the login page - window.location.reload(); - }, 5000); - } catch (error) { - showError(`Failed to restart: ${error}`); - setIsRestarting(false); - } - }; const setupSecurity = async () => { // Validate custom password if using @@ -114,7 +89,7 @@ export const QuickSecuritySetup: Component = () => { throw new Error(error || 'Failed to setup security'); } - const result = await response.json(); + // Response is successful, no need to parse result setCredentials(newCredentials); setShowCredentials(true); @@ -123,21 +98,12 @@ export const QuickSecuritySetup: Component = () => { sessionStorage.setItem('pulse_last_api_token', newCredentials.apiToken); } - // Store the command if manual action needed - if (result.command) { - (window as any).securityCommand = result.command; - } + // Show success message + showSuccess('Security configured! Settings will apply after restart.'); - // Check if we can auto-restart - if (result.readyToRestart) { - setReadyToRestart(true); - showSuccess('Security configured! Save your credentials, then click "Restart Pulse" to apply.'); - } else if (result.method === 'systemd' && !result.automatic) { - showSuccess('Security configured! Run the command shown below to apply settings.'); - } else if (result.method === 'docker') { - showSuccess('Security configured! Please restart your Docker container with the credentials shown.'); - } else { - showSuccess('Security configured! Please restart Pulse to apply settings.'); + // Notify parent component to refresh security status + if (props.onConfigured) { + props.onConfigured(); } } catch (error) { showError(`Failed to setup security: ${error}`); @@ -392,61 +358,15 @@ Important:
- -

- ✅ Security configured successfully! -

-

- Save your credentials above. Pulse will apply the security settings. -

- - } - > -

- ✅ One more step to enable security: -

-

- Run this command in your terminal: -

-
- {(window as any).securityCommand} -
-

- This will apply the settings and restart Pulse with security enabled. -

-
- } - > -

- ✅ Security configured! Ready to apply. -

-

- Make sure you've saved your credentials above before restarting. -

- - +

+ ✅ Security configured successfully! +

+

+ The service needs to be restarted for security settings to take effect. +

+

+ Save your credentials above - they won't be shown again. +

diff --git a/frontend-modern/src/components/Settings/Settings.tsx b/frontend-modern/src/components/Settings/Settings.tsx index 7792306b5..9e382969e 100644 --- a/frontend-modern/src/components/Settings/Settings.tsx +++ b/frontend-modern/src/components/Settings/Settings.tsx @@ -10,7 +10,7 @@ import { SettingsAPI } from '@/api/settings'; import { NodesAPI } from '@/api/nodes'; import { UpdatesAPI } from '@/api/updates'; import type { NodeConfig } from '@/types/nodes'; -import type { UpdateInfo, UpdateStatus, VersionInfo } from '@/api/updates'; +import type { UpdateInfo, VersionInfo } from '@/api/updates'; import { eventBus } from '@/stores/events'; import { notificationStore } from '@/stores/notifications'; @@ -85,7 +85,7 @@ type NodeConfigWithStatus = NodeConfig & { }; const Settings: Component = () => { - const { state, connected, updateProgress } = useWebSocket(); + const { state, connected } = useWebSocket(); const [activeTab, setActiveTab] = createSignal('pve'); const [hasUnsavedChanges, setHasUnsavedChanges] = createSignal(false); const [nodes, setNodes] = createSignal([]); @@ -105,7 +105,6 @@ const Settings: Component = () => { // Update settings const [versionInfo, setVersionInfo] = createSignal(null); const [updateInfo, setUpdateInfo] = createSignal(null); - const [updateStatus, setUpdateStatus] = createSignal(null); const [checkingForUpdates, setCheckingForUpdates] = createSignal(false); const [updateChannel, setUpdateChannel] = createSignal<'stable' | 'rc'>('stable'); const [autoUpdateEnabled, setAutoUpdateEnabled] = createSignal(false); @@ -123,6 +122,7 @@ const Settings: Component = () => { exportProtected: boolean; unprotectedExportAllowed: boolean; hasAuthentication: boolean; + configuredButPendingRestart?: boolean; hasAuditLogging: boolean; credentialsEncrypted: boolean; hasHTTPS: boolean; @@ -165,21 +165,6 @@ const Settings: Component = () => { } ]; - // Update status from WebSocket events - createEffect(() => { - const progress = updateProgress(); - if (progress) { - setUpdateStatus(progress); - - // Show appropriate messages based on status - if (progress.status === 'completed') { - showSuccess('Update completed! Please refresh the page (Ctrl+F5 or Cmd+Shift+R) to load the new version.'); - } else if (progress.status === 'restarting') { - showSuccess('Service is restarting. Please wait a moment then refresh the page.'); - } - } - }); - // Function to load nodes const loadNodes = async () => { try { @@ -461,85 +446,6 @@ const Settings: Component = () => { } }; - const applyUpdate = async () => { - const info = updateInfo(); - if (!info || !info.downloadUrl) { - showError('No update available'); - return; - } - - const previousVersion = versionInfo()?.version; - let connectionLostCount = 0; - let reconnectAttempts = 0; - const maxReconnectAttempts = 30; // 30 seconds max - - try { - await UpdatesAPI.applyUpdate(info.downloadUrl); - showSuccess('Update started. Pulse will restart automatically.'); - - // Start polling for update status - const pollStatus = setInterval(async () => { - try { - const status = await UpdatesAPI.getUpdateStatus(); - setUpdateStatus(status); - connectionLostCount = 0; // Reset on successful connection - - if (status.status === 'completed' || status.status === 'error') { - clearInterval(pollStatus); - if (status.status === 'error') { - showError(status.error || 'Update failed'); - } else if (status.status === 'completed') { - showSuccess('Update completed! Please refresh the page (Ctrl+F5 or Cmd+Shift+R) to load the new version.'); - } - } - } catch (error) { - // Service might be restarting - console.log('Status check failed, service may be restarting'); - connectionLostCount++; - - // If we've lost connection for a few polls, assume service is restarting - // Wait longer before trying to reconnect (service needs time to restart) - if (connectionLostCount >= 5 && reconnectAttempts < maxReconnectAttempts) { - reconnectAttempts++; - - // Try to check if the service is back with new version - try { - const newVersion = await UpdatesAPI.getVersion(); - if (newVersion.version !== previousVersion) { - clearInterval(pollStatus); - setUpdateStatus({ - status: 'completed', - progress: 100, - message: `Update successful! Now running version ${newVersion.version}. Please refresh the page.`, - updatedAt: new Date().toISOString() - }); - setVersionInfo(newVersion); - showSuccess(`Successfully updated to ${newVersion.version}! Please refresh the page (Ctrl+F5 or Cmd+Shift+R) to load the new interface.`); - } - } catch (e) { - // Service still down, keep trying - } - } - - // Give up after too many attempts - if (reconnectAttempts >= maxReconnectAttempts) { - clearInterval(pollStatus); - setUpdateStatus({ - status: 'error', - progress: 0, - message: 'Update status unknown. Please refresh the page.', - updatedAt: new Date().toISOString() - }); - showError('Could not verify update status. Please refresh the page.'); - } - } - }, 1000); - } catch (error) { - showError('Failed to start update'); - console.error('Update error:', error); - } - }; - const handleExport = async () => { if (!exportPassphrase()) { const hasAuth = securityStatus()?.hasAuthentication; @@ -1367,7 +1273,7 @@ const Settings: Component = () => { {/* Docker Message */} - +

Docker Installation: Updates are managed through Docker. Pull the latest image to update. @@ -1376,26 +1282,49 @@ const Settings: Component = () => { {/* Update Available */} - +

-
-
-

- Update Available: {updateInfo()?.latestVersion} +

+

+ Update Available: {updateInfo()?.latestVersion} +

+

+ Released: {updateInfo()?.releaseDate ? new Date(updateInfo()!.releaseDate).toLocaleDateString() : 'Unknown'} +

+
+ + {/* Update Instructions based on deployment type */} +
+

How to update:

+ +

+ Type update in the LXC console

-

- Released: {updateInfo()?.releaseDate ? new Date(updateInfo()!.releaseDate).toLocaleDateString() : 'Unknown'} + + +

+

Run these commands:

+ + docker pull rcourtman/pulse:latest
+ docker restart pulse +
+
+
+ +
+

Run the install script:

+ + curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | sudo bash + +
+
+ +

+ Pull latest changes and rebuild

-
- -
+
Release Notes @@ -1407,24 +1336,6 @@ const Settings: Component = () => {
- {/* Update Progress */} - -
-
-

- {updateStatus()?.message || 'Processing update...'} -

-
- -
-
-
-
-
-
{/* Update Settings */}
@@ -1589,9 +1500,44 @@ const Settings: Component = () => {
- {/* Show setup when no auth */} - - + {/* Show pending restart message if configured but not loaded */} + +
+
+
+ + + +
+
+

+ Security Configured - Restart Required +

+

+ Security settings have been configured but the service needs to be restarted to activate them. +

+

+ After restarting, you'll need to log in with your saved credentials. +

+
+
+
+
+ + {/* Show setup when no auth and not pending */} + + { + // Refresh security status after configuration + try { + const response = await fetch('/api/security/status'); + if (response.ok) { + const status = await response.json(); + setSecurityStatus(status); + } + } catch (err) { + console.error('Failed to refresh security status:', err); + } + }} /> {/* API Token - Show current token when auth is enabled */} diff --git a/internal/api/router.go b/internal/api/router.go index 14d776aed..c8be3757f 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -200,6 +200,21 @@ func (r *Router) setupRoutes() { // Check for basic auth configuration hasAuthentication := os.Getenv("PULSE_AUTH_USER") != "" || os.Getenv("REQUIRE_AUTH") == "true" + // Check if .env file exists but hasn't been loaded yet (pending restart) + configuredButPendingRestart := false + envPath := filepath.Join(r.config.ConfigPath, ".env") + if envPath == "" || r.config.ConfigPath == "" { + envPath = "/etc/pulse/.env" + } + + // If no auth is currently active but .env exists, security is pending restart + if !hasAuthentication && r.config.AuthUser == "" && r.config.AuthPass == "" { + if _, err := os.Stat(envPath); err == nil { + // .env exists but auth not loaded - pending restart + configuredButPendingRestart = true + } + } + // Check for audit logging hasAuditLogging := os.Getenv("PULSE_AUDIT_LOG") == "true" || os.Getenv("AUDIT_LOG_ENABLED") == "true" @@ -227,6 +242,7 @@ func (r *Router) setupRoutes() { "exportProtected": r.config.APIToken != "" || os.Getenv("ALLOW_UNPROTECTED_EXPORT") != "true", "unprotectedExportAllowed": os.Getenv("ALLOW_UNPROTECTED_EXPORT") == "true", "hasAuthentication": hasAuthentication, + "configuredButPendingRestart": configuredButPendingRestart, "hasAuditLogging": hasAuditLogging, "credentialsEncrypted": credentialsEncrypted, "hasHTTPS": req.TLS != nil, diff --git a/internal/api/security_setup_fix.go b/internal/api/security_setup_fix.go index e8665c815..44c811acd 100644 --- a/internal/api/security_setup_fix.go +++ b/internal/api/security_setup_fix.go @@ -11,6 +11,7 @@ import ( "time" "github.com/rcourtman/pulse-go-rewrite/internal/auth" + "github.com/rcourtman/pulse-go-rewrite/internal/updates" "github.com/rs/zerolog/log" ) @@ -148,6 +149,7 @@ ENABLE_AUDIT_LOG=true response := map[string]interface{}{ "success": true, "method": "docker", + "deploymentType": "docker", "requiresManualRestart": true, "message": "Security configuration saved. Restart your Docker container to apply settings.", "note": "Your credentials have been saved to /data/.env and will persist after restart.", @@ -189,6 +191,7 @@ ENABLE_AUDIT_LOG=true "method": "systemd-nonroot", "serviceName": serviceName, "envFile": envPath, + "deploymentType": updates.GetDeploymentType(), "message": fmt.Sprintf("Security settings saved to %s. Restart the %s service to apply.", envPath, serviceName), "command": fmt.Sprintf("sudo systemctl restart %s", serviceName), "note": "You may need root privileges to restart the service.", @@ -232,6 +235,7 @@ Environment="ENABLE_AUDIT_LOG=true" "success": true, "method": "systemd-root", "serviceName": serviceName, + "deploymentType": updates.GetDeploymentType(), "automatic": true, "readyToRestart": true, "message": fmt.Sprintf("Security configured! Restart %s service to apply settings.", serviceName), @@ -264,10 +268,14 @@ ENABLE_AUDIT_LOG=true // Still return success with manual instructions } + // Get deployment type for restart instructions + deploymentType := updates.GetDeploymentType() + response := map[string]interface{}{ "success": true, "method": "manual", "envFile": envPath, + "deploymentType": deploymentType, "message": "Security configuration saved. Restart Pulse to apply settings.", "note": fmt.Sprintf("Configuration saved to %s", envPath), } diff --git a/internal/updates/version.go b/internal/updates/version.go index 27289efe7..517c66853 100644 --- a/internal/updates/version.go +++ b/internal/updates/version.go @@ -21,12 +21,13 @@ type Version struct { // VersionInfo contains detailed version information type VersionInfo struct { - Version string `json:"version"` - Build string `json:"build"` - Runtime string `json:"runtime"` - Channel string `json:"channel,omitempty"` - IsDocker bool `json:"isDocker"` - IsDevelopment bool `json:"isDevelopment"` + Version string `json:"version"` + Build string `json:"build"` + Runtime string `json:"runtime"` + Channel string `json:"channel,omitempty"` + IsDocker bool `json:"isDocker"` + IsDevelopment bool `json:"isDevelopment"` + DeploymentType string `json:"deploymentType"` } // ParseVersion parses a version string into a Version struct @@ -124,12 +125,13 @@ func GetCurrentVersion() (*VersionInfo, error) { channel = "rc" } return &VersionInfo{ - Version: gitVersion, - Build: "development", - Runtime: "go", - Channel: channel, - IsDevelopment: true, - IsDocker: isDockerEnvironment(), + Version: gitVersion, + Build: "development", + Runtime: "go", + Channel: channel, + IsDevelopment: true, + IsDocker: isDockerEnvironment(), + DeploymentType: GetDeploymentType(), }, nil } @@ -150,12 +152,13 @@ func GetCurrentVersion() (*VersionInfo, error) { channel = "rc" } return &VersionInfo{ - Version: version, - Build: "release", - Runtime: "go", - Channel: channel, - IsDevelopment: false, - IsDocker: isDockerEnvironment(), + Version: version, + Build: "release", + Runtime: "go", + Channel: channel, + IsDevelopment: false, + IsDocker: isDockerEnvironment(), + DeploymentType: GetDeploymentType(), }, nil } } @@ -167,12 +170,13 @@ func GetCurrentVersion() (*VersionInfo, error) { channel = "rc" } return &VersionInfo{ - Version: version, - Build: "release", - Runtime: "go", - Channel: channel, - IsDevelopment: false, - IsDocker: isDockerEnvironment(), + Version: version, + Build: "release", + Runtime: "go", + Channel: channel, + IsDevelopment: false, + IsDocker: isDockerEnvironment(), + DeploymentType: GetDeploymentType(), }, nil } @@ -215,6 +219,46 @@ func fileExists(path string) bool { return cmd.Run() == nil } +// GetDeploymentType determines how Pulse was deployed +func GetDeploymentType() string { + // Check if running in Docker + if isDockerEnvironment() { + return "docker" + } + + // Check for ProxmoxVE LXC installation (has update command) + if fileExists("/bin/update") { + data, err := exec.Command("cat", "/bin/update").Output() + if err == nil && strings.Contains(string(data), "pulse.sh") { + return "proxmoxve" + } + } + + // Check for systemd service to determine installation type + if fileExists("/etc/systemd/system/pulse-backend.service") { + // Check if it's a ProxmoxVE installation (specific user setup) + data, err := exec.Command("cat", "/etc/systemd/system/pulse-backend.service").Output() + if err == nil { + content := string(data) + if strings.Contains(content, "User=pulse") && strings.Contains(content, "/opt/pulse/bin/pulse") { + return "proxmoxve" + } + } + return "systemd" + } + + if fileExists("/etc/systemd/system/pulse.service") { + return "systemd" + } + + // Development or manual run + if strings.Contains(os.Args[0], "go-build") || fileExists(".git") { + return "development" + } + + return "manual" +} + // compareInts compares two integers func compareInts(a, b int) int { if a < b {