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', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include', // Include cookies for CSRF
body: JSON.stringify({
username: username(),
password: finalPassword,

View file

@ -261,7 +261,7 @@ const SETTINGS_HEADER_META: Record<SettingsTab, { title: string; description: st
type NodeConfigWithStatus = NodeConfig & {
hasPassword?: boolean;
hasToken?: boolean;
status: 'connected' | 'disconnected' | 'error' | 'pending';
status: 'connected' | 'disconnected' | 'offline' | 'error' | 'pending';
};
interface SettingsProps {
@ -1678,7 +1678,8 @@ const Settings: Component<SettingsProps> = (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<SettingsProps> = (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<SettingsProps> = (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<SettingsProps> = (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<SettingsProps> = (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<SettingsProps> = (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';

View file

@ -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 {

View file

@ -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

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/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

View file

@ -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

View file

@ -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