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:
parent
5d9757ac2d
commit
669d7dc05c
7 changed files with 240 additions and 27 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue