feat: add turnkey temperature monitoring for standalone nodes

Implements automatic temperature monitoring setup for standalone
Proxmox/Pimox nodes without manual SSH key configuration.

Changes:
- Add /api/system/proxy-public-key endpoint to expose proxy's SSH public key
- Setup script now detects standalone nodes (non-cluster)
- Auto-fetches and installs proxy SSH key with forced commands
- Add Raspberry Pi temperature support via cpu_thermal and /sys/class/thermal
- Enhance setup script with better error handling for lm-sensors installation
- Add RPi detection to skip lm-sensors and use native thermal interface

Security:
- Public key endpoint is safe (public keys are meant to be public)
- All installed keys use forced command="sensors -j" with full restrictions
- No shell access, port forwarding, or other SSH features enabled
This commit is contained in:
Richard Courtman 2025-10-17 22:13:24 +00:00
parent 5d9757ac2d
commit 669d7dc05c
7 changed files with 240 additions and 27 deletions

View file

@ -108,6 +108,7 @@ export const FirstRunSetup: Component = () => {
const response = await fetch('/api/security/quick-setup', { const response = await fetch('/api/security/quick-setup', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
credentials: 'include', // Include cookies for CSRF
body: JSON.stringify({ body: JSON.stringify({
username: username(), username: username(),
password: finalPassword, password: finalPassword,

View file

@ -261,7 +261,7 @@ const SETTINGS_HEADER_META: Record<SettingsTab, { title: string; description: st
type NodeConfigWithStatus = NodeConfig & { type NodeConfigWithStatus = NodeConfig & {
hasPassword?: boolean; hasPassword?: boolean;
hasToken?: boolean; hasToken?: boolean;
status: 'connected' | 'disconnected' | 'error' | 'pending'; status: 'connected' | 'disconnected' | 'offline' | 'error' | 'pending';
}; };
interface SettingsProps { interface SettingsProps {
@ -1678,7 +1678,8 @@ const Settings: Component<SettingsProps> = (props) => {
if ( if (
stateNode?.connectionHealth === 'unhealthy' || stateNode?.connectionHealth === 'unhealthy' ||
stateNode?.connectionHealth === 'error' || stateNode?.connectionHealth === 'error' ||
stateNode?.status === 'offline' stateNode?.status === 'offline' ||
stateNode?.status === 'disconnected'
) { ) {
return 'bg-red-500'; return 'bg-red-500';
} }
@ -1698,13 +1699,14 @@ const Settings: Component<SettingsProps> = (props) => {
if (node.status === 'connected') { if (node.status === 'connected') {
return 'bg-green-500'; return 'bg-green-500';
} }
if (node.status === 'error') {
return 'bg-red-500';
}
if ( if (
node.status === 'pending' || node.status === 'error' ||
node.status === 'offline' ||
node.status === 'disconnected' node.status === 'disconnected'
) { ) {
return 'bg-red-500';
}
if (node.status === 'pending') {
return 'bg-amber-500 animate-pulse'; return 'bg-amber-500 animate-pulse';
} }
return 'bg-gray-400'; return 'bg-gray-400';
@ -2220,7 +2222,8 @@ const Settings: Component<SettingsProps> = (props) => {
if ( if (
statePBS?.connectionHealth === 'unhealthy' || statePBS?.connectionHealth === 'unhealthy' ||
statePBS?.connectionHealth === 'error' || statePBS?.connectionHealth === 'error' ||
statePBS?.status === 'offline' statePBS?.status === 'offline' ||
statePBS?.status === 'disconnected'
) { ) {
return 'bg-red-500'; return 'bg-red-500';
} }
@ -2240,13 +2243,14 @@ const Settings: Component<SettingsProps> = (props) => {
if (node.status === 'connected') { if (node.status === 'connected') {
return 'bg-green-500'; return 'bg-green-500';
} }
if (node.status === 'error') {
return 'bg-red-500';
}
if ( if (
node.status === 'pending' || node.status === 'error' ||
node.status === 'offline' ||
node.status === 'disconnected' node.status === 'disconnected'
) { ) {
return 'bg-red-500';
}
if (node.status === 'pending') {
return 'bg-amber-500 animate-pulse'; return 'bg-amber-500 animate-pulse';
} }
return 'bg-gray-400'; return 'bg-gray-400';
@ -2647,7 +2651,8 @@ const Settings: Component<SettingsProps> = (props) => {
if ( if (
statePMG?.connectionHealth === 'unhealthy' || statePMG?.connectionHealth === 'unhealthy' ||
statePMG?.connectionHealth === 'error' || statePMG?.connectionHealth === 'error' ||
statePMG?.status === 'offline' statePMG?.status === 'offline' ||
statePMG?.status === 'disconnected'
) { ) {
return 'bg-red-500'; return 'bg-red-500';
} }
@ -2660,10 +2665,14 @@ const Settings: Component<SettingsProps> = (props) => {
if (pmgNode.status === 'connected') { if (pmgNode.status === 'connected') {
return 'bg-green-500'; return 'bg-green-500';
} }
if (pmgNode.status === 'error') { if (
pmgNode.status === 'error' ||
pmgNode.status === 'offline' ||
pmgNode.status === 'disconnected'
) {
return 'bg-red-500'; return 'bg-red-500';
} }
if (pmgNode.status === 'pending' || pmgNode.status === 'disconnected') { if (pmgNode.status === 'pending') {
return 'bg-amber-500 animate-pulse'; return 'bg-amber-500 animate-pulse';
} }
return 'bg-gray-400'; return 'bg-gray-400';

View file

@ -72,8 +72,9 @@ export interface PMGNodeConfig {
export type NodeConfig = (PVENodeConfig | PBSNodeConfig | PMGNodeConfig) & { export type NodeConfig = (PVENodeConfig | PBSNodeConfig | PMGNodeConfig) & {
type: 'pve' | 'pbs' | 'pmg'; type: 'pve' | 'pbs' | 'pmg';
status?: 'connected' | 'disconnected' | 'error' | 'pending'; status?: 'connected' | 'disconnected' | 'offline' | 'error' | 'pending';
temperature?: Temperature; temperature?: Temperature;
displayName?: string;
}; };
export interface NodesResponse { export interface NodesResponse {

View file

@ -3940,17 +3940,62 @@ else
echo " ✓ SSH key configured (restricted to sensors -j)" echo " ✓ SSH key configured (restricted to sensors -j)"
fi 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 if ! command -v sensors &> /dev/null; then
echo " ✓ Installing lm-sensors..." if [ "$IS_RPI" = true ]; then
apt-get update -qq && apt-get install -y lm-sensors > /dev/null 2>&1 echo " Raspberry Pi detected - using native RPi temperature interface"
sensors-detect --auto > /dev/null 2>&1 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 else
echo " ✓ lm-sensors package verified" echo " ✓ lm-sensors package verified"
fi fi
echo "" echo ""
echo "✓ Temperature monitoring enabled" 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" echo " Temperature data will appear in the dashboard within 10 seconds"
TEMPERATURE_ENABLED=true TEMPERATURE_ENABLED=true
else else
@ -4110,6 +4155,78 @@ EOF
fi fi
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 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Setup Complete" echo "Setup Complete"
@ -4135,7 +4252,7 @@ if [ "$AUTO_REG_SUCCESS" != true ]; then
fi fi
`, serverName, time.Now().Format("2006-01-02 15:04:05"), pulseIP, `, serverName, time.Now().Format("2006-01-02 15:04:05"), pulseIP,
tokenName, tokenName, tokenName, tokenName, tokenName, tokenName, 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 } else { // PBS
script = fmt.Sprintf(`#!/bin/bash script = fmt.Sprintf(`#!/bin/bash

View file

@ -850,6 +850,7 @@ func (r *Router) setupRoutes() {
r.mux.HandleFunc("/api/system/settings", r.systemSettingsHandler.HandleGetSystemSettings) r.mux.HandleFunc("/api/system/settings", r.systemSettingsHandler.HandleGetSystemSettings)
r.mux.HandleFunc("/api/system/settings/update", r.systemSettingsHandler.HandleUpdateSystemSettings) 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/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 // Old API token endpoints removed - now using /api/security/regenerate-token
// Docker agent download endpoints // 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 { func extractSetupToken(req *http.Request) string {
if token := strings.TrimSpace(req.Header.Get("X-Setup-Token")); token != "" { if token := strings.TrimSpace(req.Header.Get("X-Setup-Token")); token != "" {
return token return token

View file

@ -65,13 +65,24 @@ func (tc *TemperatureCollector) CollectTemperature(ctx context.Context, nodeHost
} }
} else { } else {
// Direct SSH (legacy method) // 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") 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(). log.Debug().
Str("node", nodeName). Str("node", nodeName).
Str("host", host). Str("host", host).
Err(err). 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 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, "zenpower") ||
strings.Contains(chipLower, "k8temp") || strings.Contains(chipLower, "k8temp") ||
strings.Contains(chipLower, "acpitz") || strings.Contains(chipLower, "acpitz") ||
strings.Contains(chipLower, "it87") { strings.Contains(chipLower, "it87") ||
strings.Contains(chipLower, "cpu_thermal") { // Raspberry Pi CPU temperature
foundCPUChip = true foundCPUChip = true
tc.parseCPUTemps(chipMap, temp) tc.parseCPUTemps(chipMap, temp)
} }
@ -298,6 +310,35 @@ func extractCoreNumber(name string) int {
return 0 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 // extractHostname extracts hostname/IP from a Proxmox host URL
func extractHostname(hostURL string) string { func extractHostname(hostURL string) string {
// Remove protocol // Remove protocol

View file

@ -368,7 +368,7 @@ if command -v pvecm >/dev/null 2>&1; then
if [[ "$IS_LOCAL" = true ]]; then if [[ "$IS_LOCAL" = true ]]; then
configure_local_authorized_key "$AUTH_LINE" configure_local_authorized_key "$AUTH_LINE"
LOCAL_HANDLED=true LOCAL_HANDLED=true
((SSH_SUCCESS_COUNT++)) ((SSH_SUCCESS_COUNT+=1))
continue continue
fi fi
@ -381,10 +381,10 @@ if command -v pvecm >/dev/null 2>&1; then
"echo '${AUTH_LINE}' >> /root/.ssh/authorized_keys" 2>&1) "echo '${AUTH_LINE}' >> /root/.ssh/authorized_keys" 2>&1)
if [[ $? -eq 0 ]]; then if [[ $? -eq 0 ]]; then
print_success "SSH key configured on $node_ip" print_success "SSH key configured on $node_ip"
((SSH_SUCCESS_COUNT++)) ((SSH_SUCCESS_COUNT+=1))
else else
print_warn "Failed to configure SSH key on $node_ip" print_warn "Failed to configure SSH key on $node_ip"
((SSH_FAILURE_COUNT++)) ((SSH_FAILURE_COUNT+=1))
SSH_FAILED_NODES+=("$node_ip") SSH_FAILED_NODES+=("$node_ip")
# Log detailed error for debugging # Log detailed error for debugging
if [[ -n "$SSH_ERROR" ]]; then if [[ -n "$SSH_ERROR" ]]; then
@ -405,7 +405,7 @@ if command -v pvecm >/dev/null 2>&1; then
fi fi
if [[ "$LOCAL_HANDLED" = false ]]; then if [[ "$LOCAL_HANDLED" = false ]]; then
configure_local_authorized_key "$AUTH_LINE" configure_local_authorized_key "$AUTH_LINE"
((SSH_SUCCESS_COUNT++)) ((SSH_SUCCESS_COUNT+=1))
fi fi
else else
# No cluster found - configure standalone node # No cluster found - configure standalone node