diff --git a/frontend-modern/src/components/FirstRunSetup.tsx b/frontend-modern/src/components/FirstRunSetup.tsx index 58b1eb0..69f152c 100644 --- a/frontend-modern/src/components/FirstRunSetup.tsx +++ b/frontend-modern/src/components/FirstRunSetup.tsx @@ -108,6 +108,7 @@ export const FirstRunSetup: Component = () => { const response = await fetch('/api/security/quick-setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + credentials: 'include', // Include cookies for CSRF body: JSON.stringify({ username: username(), password: finalPassword, diff --git a/frontend-modern/src/components/Settings/Settings.tsx b/frontend-modern/src/components/Settings/Settings.tsx index a74ea0a..4ee952f 100644 --- a/frontend-modern/src/components/Settings/Settings.tsx +++ b/frontend-modern/src/components/Settings/Settings.tsx @@ -261,7 +261,7 @@ const SETTINGS_HEADER_META: Record = (props) => { if ( stateNode?.connectionHealth === 'unhealthy' || stateNode?.connectionHealth === 'error' || - stateNode?.status === 'offline' + stateNode?.status === 'offline' || + stateNode?.status === 'disconnected' ) { return 'bg-red-500'; } @@ -1698,13 +1699,14 @@ const Settings: Component = (props) => { if (node.status === 'connected') { return 'bg-green-500'; } - if (node.status === 'error') { - return 'bg-red-500'; - } if ( - node.status === 'pending' || + node.status === 'error' || + node.status === 'offline' || node.status === 'disconnected' ) { + return 'bg-red-500'; + } + if (node.status === 'pending') { return 'bg-amber-500 animate-pulse'; } return 'bg-gray-400'; @@ -2220,7 +2222,8 @@ const Settings: Component = (props) => { if ( statePBS?.connectionHealth === 'unhealthy' || statePBS?.connectionHealth === 'error' || - statePBS?.status === 'offline' + statePBS?.status === 'offline' || + statePBS?.status === 'disconnected' ) { return 'bg-red-500'; } @@ -2240,13 +2243,14 @@ const Settings: Component = (props) => { if (node.status === 'connected') { return 'bg-green-500'; } - if (node.status === 'error') { - return 'bg-red-500'; - } if ( - node.status === 'pending' || + node.status === 'error' || + node.status === 'offline' || node.status === 'disconnected' ) { + return 'bg-red-500'; + } + if (node.status === 'pending') { return 'bg-amber-500 animate-pulse'; } return 'bg-gray-400'; @@ -2647,7 +2651,8 @@ const Settings: Component = (props) => { if ( statePMG?.connectionHealth === 'unhealthy' || statePMG?.connectionHealth === 'error' || - statePMG?.status === 'offline' + statePMG?.status === 'offline' || + statePMG?.status === 'disconnected' ) { return 'bg-red-500'; } @@ -2660,10 +2665,14 @@ const Settings: Component = (props) => { if (pmgNode.status === 'connected') { return 'bg-green-500'; } - if (pmgNode.status === 'error') { + if ( + pmgNode.status === 'error' || + pmgNode.status === 'offline' || + pmgNode.status === 'disconnected' + ) { return 'bg-red-500'; } - if (pmgNode.status === 'pending' || pmgNode.status === 'disconnected') { + if (pmgNode.status === 'pending') { return 'bg-amber-500 animate-pulse'; } return 'bg-gray-400'; diff --git a/frontend-modern/src/types/nodes.ts b/frontend-modern/src/types/nodes.ts index efddc1b..dcf0a1f 100644 --- a/frontend-modern/src/types/nodes.ts +++ b/frontend-modern/src/types/nodes.ts @@ -72,8 +72,9 @@ export interface PMGNodeConfig { export type NodeConfig = (PVENodeConfig | PBSNodeConfig | PMGNodeConfig) & { type: 'pve' | 'pbs' | 'pmg'; - status?: 'connected' | 'disconnected' | 'error' | 'pending'; + status?: 'connected' | 'disconnected' | 'offline' | 'error' | 'pending'; temperature?: Temperature; + displayName?: string; }; export interface NodesResponse { diff --git a/internal/api/config_handlers.go b/internal/api/config_handlers.go index a1ec8c0..3a62e9a 100644 --- a/internal/api/config_handlers.go +++ b/internal/api/config_handlers.go @@ -3940,17 +3940,62 @@ else echo " ✓ SSH key configured (restricted to sensors -j)" fi - # Install lm-sensors if not present + # Check if this is a Raspberry Pi + IS_RPI=false + if [ -f /proc/device-tree/model ] && grep -qi "raspberry pi" /proc/device-tree/model 2>/dev/null; then + IS_RPI=true + fi + + # Install lm-sensors if not present (skip on Raspberry Pi) if ! command -v sensors &> /dev/null; then - echo " ✓ Installing lm-sensors..." - apt-get update -qq && apt-get install -y lm-sensors > /dev/null 2>&1 - sensors-detect --auto > /dev/null 2>&1 + if [ "$IS_RPI" = true ]; then + echo " ℹ️ Raspberry Pi detected - using native RPi temperature interface" + echo " Pulse will read temperature from /sys/class/thermal/thermal_zone0/temp" + else + echo " ✓ Installing lm-sensors..." + + # Try to update and install, but provide helpful errors if it fails + UPDATE_OUTPUT=$(apt-get update -qq 2>&1) + if echo "$UPDATE_OUTPUT" | grep -q "Could not create temporary file\|/tmp"; then + echo "" + echo " ⚠️ APT cannot write to /tmp directory" + echo " This may be a permissions issue. To fix:" + echo " sudo chown root:root /tmp" + echo " sudo chmod 1777 /tmp" + echo "" + echo " Attempting installation anyway..." + elif echo "$UPDATE_OUTPUT" | grep -q "Failed to fetch\|GPG error\|no longer has a Release file"; then + echo " ⚠️ Some repository errors detected, attempting installation anyway..." + fi + + if apt-get install -y lm-sensors > /dev/null 2>&1; then + sensors-detect --auto > /dev/null 2>&1 || true + echo " ✓ lm-sensors installed successfully" + else + echo "" + echo " ⚠️ Could not install lm-sensors" + echo " Possible causes:" + echo " - Repository configuration errors" + echo " - /tmp directory permission issues" + echo " - Network connectivity problems" + echo "" + echo " To fix manually:" + echo " 1. Check /tmp permissions: ls -ld /tmp" + echo " (should be: drwxrwxrwt owned by root:root)" + echo " 2. Fix if needed: sudo chown root:root /tmp && sudo chmod 1777 /tmp" + echo " 3. Install: sudo apt-get update && sudo apt-get install -y lm-sensors" + echo "" + fi + fi else echo " ✓ lm-sensors package verified" fi echo "" echo "✓ Temperature monitoring enabled" + if [ "$IS_RPI" = true ]; then + echo " Using Raspberry Pi native temperature interface" + fi echo " Temperature data will appear in the dashboard within 10 seconds" TEMPERATURE_ENABLED=true else @@ -4110,6 +4155,78 @@ EOF fi fi +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# Standalone Node Configuration (for non-cluster nodes) +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +# Check if this node is standalone (not in a cluster) +IS_STANDALONE=false +if ! command -v pvecm >/dev/null 2>&1 || ! pvecm status >/dev/null 2>&1; then + IS_STANDALONE=true +fi + +# If standalone and temperature monitoring was enabled, try to fetch proxy key +if [ "$IS_STANDALONE" = true ] && [ "$TEMPERATURE_ENABLED" = true ]; then + echo "" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "Standalone Node Temperature Setup" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "Detected: This is a standalone node (not in a Proxmox cluster)" + echo "" + echo "For enhanced security with containerized Pulse, we'll fetch the" + echo "temperature proxy's SSH key directly from your Pulse server." + echo "" + + # Try to fetch the proxy's public key from Pulse server + PROXY_KEY_URL="%s/api/system/proxy-public-key" + echo "Fetching temperature proxy public key..." + + PROXY_PUBLIC_KEY=$(curl -s -f "$PROXY_KEY_URL" 2>/dev/null || echo "") + + if [ -n "$PROXY_PUBLIC_KEY" ] && [[ "$PROXY_PUBLIC_KEY" =~ ^ssh-(rsa|ed25519) ]]; then + echo " ✓ Retrieved proxy public key" + + # Build the forced command entry for the proxy key + PROXY_RESTRICTED_KEY="command=\"sensors -j\",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty $PROXY_PUBLIC_KEY # pulse-proxy-key" + + # Check if key already exists + if [ -f /root/.ssh/authorized_keys ] && grep -qF "$PROXY_PUBLIC_KEY" /root/.ssh/authorized_keys 2>/dev/null; then + echo " ✓ Proxy key already configured" + else + # Add the proxy key + mkdir -p /root/.ssh + chmod 700 /root/.ssh + + # Remove any old pulse-proxy-key entries first + if [ -f /root/.ssh/authorized_keys ]; then + grep -v '# pulse-proxy-key' /root/.ssh/authorized_keys > /root/.ssh/authorized_keys.tmp 2>/dev/null || true + mv /root/.ssh/authorized_keys.tmp /root/.ssh/authorized_keys + fi + + # Add the new proxy key + echo "$PROXY_RESTRICTED_KEY" >> /root/.ssh/authorized_keys + chmod 600 /root/.ssh/authorized_keys + echo " ✓ Temperature proxy key installed (restricted to sensors -j)" + fi + + echo "" + echo "✓ Standalone node temperature monitoring configured" + echo " The Pulse temperature proxy can now collect temperature data" + echo " from this node using secure SSH with forced commands." + echo "" + else + echo " ℹ️ Could not retrieve proxy public key from Pulse server" + echo "" + echo "This is normal if:" + echo " • Pulse is not running in a container (uses direct SSH)" + echo " • The temperature proxy service is not installed on the host" + echo "" + echo "Temperature monitoring will use the standard SSH key configured earlier." + echo "" + fi +fi + echo "" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "Setup Complete" @@ -4135,7 +4252,7 @@ if [ "$AUTO_REG_SUCCESS" != true ]; then fi `, serverName, time.Now().Format("2006-01-02 15:04:05"), pulseIP, tokenName, tokenName, tokenName, tokenName, tokenName, tokenName, - authToken, pulseURL, serverHost, tokenName, tokenName, storagePerms, sshPublicKey, pulseURL, pulseURL, pulseURL, pulseURL, authToken, tokenName, serverHost) + authToken, pulseURL, serverHost, tokenName, tokenName, storagePerms, sshPublicKey, pulseURL, pulseURL, pulseURL, pulseURL, authToken, pulseURL, tokenName, serverHost) } else { // PBS script = fmt.Sprintf(`#!/bin/bash diff --git a/internal/api/router.go b/internal/api/router.go index 74cd183..a75f380 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -850,6 +850,7 @@ func (r *Router) setupRoutes() { r.mux.HandleFunc("/api/system/settings", r.systemSettingsHandler.HandleGetSystemSettings) r.mux.HandleFunc("/api/system/settings/update", r.systemSettingsHandler.HandleUpdateSystemSettings) r.mux.HandleFunc("/api/system/verify-temperature-ssh", r.handleVerifyTemperatureSSH) + r.mux.HandleFunc("/api/system/proxy-public-key", r.handleProxyPublicKey) // Old API token endpoints removed - now using /api/security/regenerate-token // Docker agent download endpoints @@ -905,6 +906,49 @@ func (r *Router) handleVerifyTemperatureSSH(w http.ResponseWriter, req *http.Req } } +// handleProxyPublicKey returns the temperature proxy's public SSH key (public endpoint) +func (r *Router) handleProxyPublicKey(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Try to get the proxy's public key + proxyClient := tempproxy.NewClient() + if !proxyClient.IsAvailable() { + // Proxy not available - return empty response + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusNotFound) + w.Write([]byte("")) + return + } + + // Get proxy status which includes the public key + status, err := proxyClient.GetStatus() + if err != nil { + log.Warn().Err(err).Msg("Failed to get proxy status") + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusServiceUnavailable) + w.Write([]byte("")) + return + } + + // Extract public key + publicKey, ok := status["public_key"].(string) + if !ok || publicKey == "" { + log.Warn().Msg("Public key not found in proxy status") + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusNotFound) + w.Write([]byte("")) + return + } + + // Return the public key as plain text + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + w.Write([]byte(publicKey)) +} + func extractSetupToken(req *http.Request) string { if token := strings.TrimSpace(req.Header.Get("X-Setup-Token")); token != "" { return token diff --git a/internal/monitoring/temperature.go b/internal/monitoring/temperature.go index 0587e6b..af161d8 100644 --- a/internal/monitoring/temperature.go +++ b/internal/monitoring/temperature.go @@ -65,13 +65,24 @@ func (tc *TemperatureCollector) CollectTemperature(ctx context.Context, nodeHost } } else { // Direct SSH (legacy method) + // Try sensors first, fall back to Raspberry Pi method if that fails output, err = tc.runSSHCommand(ctx, host, "sensors -j 2>/dev/null") - if err != nil { + if err != nil || strings.TrimSpace(output) == "" { + // Try Raspberry Pi temperature method + output, err = tc.runSSHCommand(ctx, host, "cat /sys/class/thermal/thermal_zone0/temp 2>/dev/null") + if err == nil && strings.TrimSpace(output) != "" { + // Parse RPi temperature format + temp, parseErr := tc.parseRPiTemperature(output) + if parseErr == nil { + return temp, nil + } + } + log.Debug(). Str("node", nodeName). Str("host", host). Err(err). - Msg("Failed to collect temperature data via SSH") + Msg("Failed to collect temperature data via SSH (tried both lm-sensors and RPi methods)") return &models.Temperature{Available: false}, nil } } @@ -181,7 +192,8 @@ func (tc *TemperatureCollector) parseSensorsJSON(jsonStr string) (*models.Temper strings.Contains(chipLower, "zenpower") || strings.Contains(chipLower, "k8temp") || strings.Contains(chipLower, "acpitz") || - strings.Contains(chipLower, "it87") { + strings.Contains(chipLower, "it87") || + strings.Contains(chipLower, "cpu_thermal") { // Raspberry Pi CPU temperature foundCPUChip = true tc.parseCPUTemps(chipMap, temp) } @@ -298,6 +310,35 @@ func extractCoreNumber(name string) int { return 0 } +// parseRPiTemperature parses Raspberry Pi temperature from /sys/class/thermal/thermal_zone0/temp +// Format: integer representing millidegrees Celsius (e.g., "45678" = 45.678°C) +func (tc *TemperatureCollector) parseRPiTemperature(output string) (*models.Temperature, error) { + millidegrees := strings.TrimSpace(output) + if millidegrees == "" { + return nil, fmt.Errorf("empty RPi temperature output") + } + + tempMilliC, err := strconv.ParseFloat(millidegrees, 64) + if err != nil { + return nil, fmt.Errorf("failed to parse RPi temperature: %w", err) + } + + // Convert millidegrees to degrees Celsius + tempC := tempMilliC / 1000.0 + + temp := &models.Temperature{ + Available: true, + HasCPU: true, + CPUPackage: tempC, + CPUMax: tempC, + Cores: []models.CoreTemp{}, + NVMe: []models.NVMeTemp{}, + LastUpdate: time.Now(), + } + + return temp, nil +} + // extractHostname extracts hostname/IP from a Proxmox host URL func extractHostname(hostURL string) string { // Remove protocol diff --git a/scripts/install-sensor-proxy.sh b/scripts/install-sensor-proxy.sh index ed8cb04..3b28a7b 100755 --- a/scripts/install-sensor-proxy.sh +++ b/scripts/install-sensor-proxy.sh @@ -368,7 +368,7 @@ if command -v pvecm >/dev/null 2>&1; then if [[ "$IS_LOCAL" = true ]]; then configure_local_authorized_key "$AUTH_LINE" LOCAL_HANDLED=true - ((SSH_SUCCESS_COUNT++)) + ((SSH_SUCCESS_COUNT+=1)) continue fi @@ -381,10 +381,10 @@ if command -v pvecm >/dev/null 2>&1; then "echo '${AUTH_LINE}' >> /root/.ssh/authorized_keys" 2>&1) if [[ $? -eq 0 ]]; then print_success "SSH key configured on $node_ip" - ((SSH_SUCCESS_COUNT++)) + ((SSH_SUCCESS_COUNT+=1)) else print_warn "Failed to configure SSH key on $node_ip" - ((SSH_FAILURE_COUNT++)) + ((SSH_FAILURE_COUNT+=1)) SSH_FAILED_NODES+=("$node_ip") # Log detailed error for debugging if [[ -n "$SSH_ERROR" ]]; then @@ -405,7 +405,7 @@ if command -v pvecm >/dev/null 2>&1; then fi if [[ "$LOCAL_HANDLED" = false ]]; then configure_local_authorized_key "$AUTH_LINE" - ((SSH_SUCCESS_COUNT++)) + ((SSH_SUCCESS_COUNT+=1)) fi else # No cluster found - configure standalone node