diff --git a/cmd/pulse-temp-proxy/main.go b/cmd/pulse-temp-proxy/main.go new file mode 100644 index 0000000..b167061 --- /dev/null +++ b/cmd/pulse-temp-proxy/main.go @@ -0,0 +1,426 @@ +package main + +import ( + "encoding/json" + "fmt" + "net" + "os" + "os/signal" + "path/filepath" + "syscall" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" +) + +// Version information (set at build time with -ldflags) +var ( + Version = "dev" + BuildTime = "unknown" + GitCommit = "unknown" +) + +const ( + defaultSocketPath = "/var/run/pulse-temp-proxy.sock" + defaultSSHKeyPath = "/var/lib/pulse-temp-proxy/ssh" +) + +var rootCmd = &cobra.Command{ + Use: "pulse-temp-proxy", + Short: "Pulse Temperature Proxy - Secure SSH bridge for containerized Pulse", + Long: `Temperature monitoring proxy that keeps SSH keys on the host and exposes temperature data via unix socket`, + Version: Version, + Run: func(cmd *cobra.Command, args []string) { + runProxy() + }, +} + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print version information", + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf("pulse-temp-proxy %s\n", Version) + if BuildTime != "unknown" { + fmt.Printf("Built: %s\n", BuildTime) + } + if GitCommit != "unknown" { + fmt.Printf("Commit: %s\n", GitCommit) + } + }, +} + +func init() { + rootCmd.AddCommand(versionCmd) +} + +func main() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +// Proxy manages the temperature monitoring proxy +type Proxy struct { + socketPath string + sshKeyPath string + listener net.Listener +} + +// RPC request types +const ( + RPCEnsureClusterKeys = "ensure_cluster_keys" + RPCRegisterNodes = "register_nodes" + RPCGetTemperature = "get_temperature" + RPCGetStatus = "get_status" +) + +// RPCRequest represents a request from Pulse +type RPCRequest struct { + Method string `json:"method"` + Params map[string]interface{} `json:"params"` +} + +// RPCResponse represents a response to Pulse +type RPCResponse struct { + Success bool `json:"success"` + Data interface{} `json:"data,omitempty"` + Error string `json:"error,omitempty"` +} + +func runProxy() { + // Initialize logger + zerolog.TimeFieldFormat = zerolog.TimeFormatUnix + log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) + + socketPath := os.Getenv("PULSE_TEMP_PROXY_SOCKET") + if socketPath == "" { + socketPath = defaultSocketPath + } + + sshKeyPath := os.Getenv("PULSE_TEMP_PROXY_SSH_DIR") + if sshKeyPath == "" { + sshKeyPath = defaultSSHKeyPath + } + + log.Info(). + Str("socket", socketPath). + Str("ssh_key_dir", sshKeyPath). + Msg("Starting pulse-temp-proxy") + + proxy := &Proxy{ + socketPath: socketPath, + sshKeyPath: sshKeyPath, + } + + if err := proxy.Start(); err != nil { + log.Fatal().Err(err).Msg("Failed to start proxy") + } + + // Setup signal handlers + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + + <-sigChan + log.Info().Msg("Shutting down proxy...") + proxy.Stop() + log.Info().Msg("Proxy stopped") +} + +// Start initializes and starts the proxy +func (p *Proxy) Start() error { + // Create SSH key directory if it doesn't exist + if err := os.MkdirAll(p.sshKeyPath, 0700); err != nil { + return fmt.Errorf("failed to create SSH key directory: %w", err) + } + + // Ensure SSH keypair exists + if err := p.ensureSSHKeypair(); err != nil { + return fmt.Errorf("failed to ensure SSH keypair: %w", err) + } + + // Remove existing socket if it exists + if err := os.RemoveAll(p.socketPath); err != nil { + return fmt.Errorf("failed to remove existing socket: %w", err) + } + + // Create socket directory if needed + socketDir := filepath.Dir(p.socketPath) + if err := os.MkdirAll(socketDir, 0755); err != nil { + return fmt.Errorf("failed to create socket directory: %w", err) + } + + // Create unix socket listener + listener, err := net.Listen("unix", p.socketPath) + if err != nil { + return fmt.Errorf("failed to create unix socket: %w", err) + } + p.listener = listener + + // Set socket permissions so Pulse container can access it + if err := os.Chmod(p.socketPath, 0666); err != nil { + log.Warn().Err(err).Msg("Failed to set socket permissions") + } + + log.Info().Str("socket", p.socketPath).Msg("Unix socket ready") + + // Start accepting connections + go p.acceptConnections() + + return nil +} + +// Stop shuts down the proxy +func (p *Proxy) Stop() { + if p.listener != nil { + p.listener.Close() + os.Remove(p.socketPath) + } +} + +// acceptConnections handles incoming socket connections +func (p *Proxy) acceptConnections() { + for { + conn, err := p.listener.Accept() + if err != nil { + // Check if listener was closed + if opErr, ok := err.(*net.OpError); ok && opErr.Err.Error() == "use of closed network connection" { + return + } + log.Error().Err(err).Msg("Failed to accept connection") + continue + } + + go p.handleConnection(conn) + } +} + +// handleConnection processes a single RPC request +func (p *Proxy) handleConnection(conn net.Conn) { + defer conn.Close() + + // Decode request + var req RPCRequest + decoder := json.NewDecoder(conn) + if err := decoder.Decode(&req); err != nil { + log.Error().Err(err).Msg("Failed to decode RPC request") + p.sendError(conn, "invalid request format") + return + } + + log.Debug().Str("method", req.Method).Msg("Received RPC request") + + // Route to handler + var resp RPCResponse + switch req.Method { + case RPCGetStatus: + resp = p.handleGetStatus(req) + case RPCEnsureClusterKeys: + resp = p.handleEnsureClusterKeys(req) + case RPCRegisterNodes: + resp = p.handleRegisterNodes(req) + case RPCGetTemperature: + resp = p.handleGetTemperature(req) + default: + resp = RPCResponse{ + Success: false, + Error: fmt.Sprintf("unknown method: %s", req.Method), + } + } + + // Send response + encoder := json.NewEncoder(conn) + if err := encoder.Encode(resp); err != nil { + log.Error().Err(err).Msg("Failed to encode RPC response") + } +} + +// sendError sends an error response +func (p *Proxy) sendError(conn net.Conn, message string) { + resp := RPCResponse{ + Success: false, + Error: message, + } + encoder := json.NewEncoder(conn) + encoder.Encode(resp) +} + +// handleGetStatus returns proxy status +func (p *Proxy) handleGetStatus(req RPCRequest) RPCResponse { + pubKeyPath := filepath.Join(p.sshKeyPath, "id_ed25519.pub") + pubKey, err := os.ReadFile(pubKeyPath) + if err != nil { + return RPCResponse{ + Success: false, + Error: fmt.Sprintf("failed to read public key: %v", err), + } + } + + return RPCResponse{ + Success: true, + Data: map[string]interface{}{ + "version": Version, + "public_key": string(pubKey), + "ssh_dir": p.sshKeyPath, + }, + } +} + +// ensureSSHKeypair generates SSH keypair if it doesn't exist +func (p *Proxy) ensureSSHKeypair() error { + privKeyPath := filepath.Join(p.sshKeyPath, "id_ed25519") + pubKeyPath := filepath.Join(p.sshKeyPath, "id_ed25519.pub") + + // Check if keypair already exists + if _, err := os.Stat(privKeyPath); err == nil { + if _, err := os.Stat(pubKeyPath); err == nil { + log.Info().Msg("SSH keypair already exists") + return nil + } + } + + log.Info().Msg("Generating new SSH keypair") + + // Generate ed25519 keypair using ssh-keygen + cmd := fmt.Sprintf("ssh-keygen -t ed25519 -f %s -N '' -C 'pulse-temp-proxy'", privKeyPath) + if output, err := execCommand(cmd); err != nil { + return fmt.Errorf("failed to generate SSH keypair: %w (output: %s)", err, output) + } + + log.Info().Str("path", privKeyPath).Msg("SSH keypair generated") + return nil +} + +// handleEnsureClusterKeys discovers cluster nodes and pushes SSH keys +func (p *Proxy) handleEnsureClusterKeys(req RPCRequest) RPCResponse { + // Check if we're on a Proxmox host + if !isProxmoxHost() { + return RPCResponse{ + Success: false, + Error: "not running on Proxmox host - cannot discover cluster", + } + } + + // Discover cluster nodes + nodes, err := discoverClusterNodes() + if err != nil { + return RPCResponse{ + Success: false, + Error: fmt.Sprintf("failed to discover cluster: %v", err), + } + } + + log.Info().Strs("nodes", nodes).Msg("Discovered cluster nodes") + + // Push SSH key to each node + results := make(map[string]interface{}) + successCount := 0 + for _, node := range nodes { + log.Info().Str("node", node).Msg("Pushing SSH key to node") + if err := p.pushSSHKey(node); err != nil { + log.Error().Err(err).Str("node", node).Msg("Failed to push SSH key") + results[node] = map[string]interface{}{ + "success": false, + "error": err.Error(), + } + } else { + log.Info().Str("node", node).Msg("SSH key pushed successfully") + results[node] = map[string]interface{}{ + "success": true, + } + successCount++ + } + } + + return RPCResponse{ + Success: true, + Data: map[string]interface{}{ + "nodes": nodes, + "results": results, + "success_count": successCount, + "total_count": len(nodes), + }, + } +} + +// handleRegisterNodes returns discovered nodes +func (p *Proxy) handleRegisterNodes(req RPCRequest) RPCResponse { + // Check if we're on a Proxmox host + if !isProxmoxHost() { + return RPCResponse{ + Success: false, + Error: "not running on Proxmox host", + } + } + + // Discover cluster nodes + nodes, err := discoverClusterNodes() + if err != nil { + return RPCResponse{ + Success: false, + Error: fmt.Sprintf("failed to discover nodes: %v", err), + } + } + + // Test SSH connectivity to each node + nodeStatus := make([]map[string]interface{}, 0, len(nodes)) + for _, node := range nodes { + status := map[string]interface{}{ + "name": node, + } + + if err := p.testSSHConnection(node); err != nil { + status["ssh_ready"] = false + status["error"] = err.Error() + } else { + status["ssh_ready"] = true + } + + nodeStatus = append(nodeStatus, status) + } + + return RPCResponse{ + Success: true, + Data: map[string]interface{}{ + "nodes": nodeStatus, + }, + } +} + +// handleGetTemperature fetches temperature data from a node via SSH +func (p *Proxy) handleGetTemperature(req RPCRequest) RPCResponse { + // Extract node parameter + nodeParam, ok := req.Params["node"] + if !ok { + return RPCResponse{ + Success: false, + Error: "missing 'node' parameter", + } + } + + node, ok := nodeParam.(string) + if !ok { + return RPCResponse{ + Success: false, + Error: "'node' parameter must be a string", + } + } + + // Fetch temperature data + tempData, err := p.getTemperatureViaSSH(node) + if err != nil { + return RPCResponse{ + Success: false, + Error: fmt.Sprintf("failed to get temperatures: %v", err), + } + } + + return RPCResponse{ + Success: true, + Data: map[string]interface{}{ + "node": node, + "temperature": tempData, + }, + } +} diff --git a/cmd/pulse-temp-proxy/ssh.go b/cmd/pulse-temp-proxy/ssh.go new file mode 100644 index 0000000..23a9d7a --- /dev/null +++ b/cmd/pulse-temp-proxy/ssh.go @@ -0,0 +1,161 @@ +package main + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// execCommand executes a shell command and returns output +func execCommand(cmd string) (string, error) { + out, err := exec.Command("sh", "-c", cmd).CombinedOutput() + return string(out), err +} + +// getPublicKey reads the SSH public key +func (p *Proxy) getPublicKey() (string, error) { + pubKeyPath := filepath.Join(p.sshKeyPath, "id_ed25519.pub") + data, err := os.ReadFile(pubKeyPath) + if err != nil { + return "", err + } + return strings.TrimSpace(string(data)), nil +} + +// pushSSHKey adds the proxy's public key to a node's authorized_keys with restrictions +func (p *Proxy) pushSSHKey(nodeHost string) error { + pubKey, err := p.getPublicKey() + if err != nil { + return fmt.Errorf("failed to get public key: %w", err) + } + + // Create forced command entry with restrictions + // This limits the key to only running "sensors -j" + authorizedKey := fmt.Sprintf(`command="sensors -j",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty %s`, pubKey) + + // Build SSH command to add key to remote node + // First, check if key already exists to avoid duplicates + checkCmd := fmt.Sprintf( + `ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 root@%s "grep -F '%s' /root/.ssh/authorized_keys 2>/dev/null"`, + nodeHost, + pubKey, + ) + + if output, _ := execCommand(checkCmd); strings.Contains(output, pubKey) { + return nil // Key already exists + } + + // Add the key + addCmd := fmt.Sprintf( + `ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 root@%s "mkdir -p /root/.ssh && chmod 700 /root/.ssh && echo '%s' >> /root/.ssh/authorized_keys && chmod 600 /root/.ssh/authorized_keys"`, + nodeHost, + authorizedKey, + ) + + if _, err := execCommand(addCmd); err != nil { + return fmt.Errorf("failed to add SSH key to %s: %w", nodeHost, err) + } + + return nil +} + +// testSSHConnection verifies SSH connectivity to a node +func (p *Proxy) testSSHConnection(nodeHost string) error { + privKeyPath := filepath.Join(p.sshKeyPath, "id_ed25519") + cmd := fmt.Sprintf( + `ssh -i %s -o StrictHostKeyChecking=no -o ConnectTimeout=5 root@%s "echo test"`, + privKeyPath, + nodeHost, + ) + + output, err := execCommand(cmd) + if err != nil { + return fmt.Errorf("SSH test failed: %w (output: %s)", err, output) + } + + // The forced command will run "sensors -j" instead of "echo test" + // So we should get JSON output, not "test" + // For now, just check that connection succeeded + return nil +} + +// getTemperatureViaSSH fetches temperature data from a node +func (p *Proxy) getTemperatureViaSSH(nodeHost string) (string, error) { + privKeyPath := filepath.Join(p.sshKeyPath, "id_ed25519") + + // Since we use ForceCommand="sensors -j", any SSH command will run sensors + // We don't need to specify the command + cmd := fmt.Sprintf( + `ssh -i %s -o StrictHostKeyChecking=no -o ConnectTimeout=5 root@%s ""`, + privKeyPath, + nodeHost, + ) + + output, err := execCommand(cmd) + if err != nil { + return "", fmt.Errorf("failed to fetch temperatures: %w", err) + } + + return output, nil +} + +// discoverClusterNodes discovers all nodes in the Proxmox cluster +func discoverClusterNodes() ([]string, error) { + // Check if pvecm is available (only on Proxmox hosts) + if _, err := exec.LookPath("pvecm"); err != nil { + return nil, fmt.Errorf("pvecm not found - not running on Proxmox host") + } + + // Get cluster node list + cmd := exec.Command("pvecm", "nodes") + var out bytes.Buffer + cmd.Stdout = &out + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("failed to get cluster nodes: %w", err) + } + + // Parse output + // Format: + // Membership information + // ---------------------- + // Nodeid Votes Name + // 1 1 node1 + // 2 1 node2 + + var nodes []string + lines := strings.Split(out.String(), "\n") + for _, line := range lines { + fields := strings.Fields(line) + // Skip header lines and empty lines + if len(fields) < 3 { + continue + } + // Check if first field is numeric (node ID) + if fields[0][0] >= '0' && fields[0][0] <= '9' { + nodeName := fields[2] + nodes = append(nodes, nodeName) + } + } + + if len(nodes) == 0 { + return nil, fmt.Errorf("no cluster nodes found") + } + + return nodes, nil +} + +// isProxmoxHost checks if we're running on a Proxmox host +func isProxmoxHost() bool { + // Check for pvecm command + if _, err := exec.LookPath("pvecm"); err == nil { + return true + } + // Check for /etc/pve directory + if info, err := os.Stat("/etc/pve"); err == nil && info.IsDir() { + return true + } + return false +} diff --git a/docs/TEMPERATURE_MONITORING.md b/docs/TEMPERATURE_MONITORING.md index c77ab7a..73ad92f 100644 --- a/docs/TEMPERATURE_MONITORING.md +++ b/docs/TEMPERATURE_MONITORING.md @@ -14,9 +14,30 @@ Pulse can display real-time CPU and NVMe temperatures directly in your dashboard ## How It Works -Temperature monitoring uses standard SSH key authentication (just like Ansible, Saltstack, and other automation tools) to securely collect sensor data from your nodes. Pulse connects via SSH and runs the `sensors` command to read hardware temperatures - that's it! +### Secure Architecture (v4.24.0+) -> **Important:** Run every setup command as the same user account that executes the Pulse service (typically `pulse`). The backend reads the SSH key from that user’s home directory; keys under `root` or other accounts will be ignored. +For **containerized deployments** (LXC/Docker), Pulse uses a secure proxy architecture: + +1. **pulse-temp-proxy** runs on the Proxmox host (outside the container) +2. SSH keys are stored on the host filesystem (`/var/lib/pulse-temp-proxy/ssh/`) +3. Pulse communicates with the proxy via unix socket +4. The proxy handles all SSH connections to cluster nodes + +**Benefits:** +- SSH keys never enter the container +- Container compromise doesn't expose infrastructure credentials +- Automatically configured during installation +- Transparent to users - no setup changes + +### Legacy Architecture (Pre-v4.24.0 / Native Installs) + +For native (non-containerized) installations, Pulse connects directly via SSH: + +1. Pulse uses SSH key authentication (like Ansible, Terraform, etc.) +2. Runs `sensors -j` command to read hardware temperatures +3. SSH key stored in Pulse's home directory + +> **Important for native installs:** Run every setup command as the same user account that executes the Pulse service (typically `pulse`). The backend reads the SSH key from that user's home directory. ## Requirements @@ -176,27 +197,36 @@ You can still manage the entry manually if you prefer, but no extra steps are re ## Container Security Considerations -⚠️ **Important for Docker/LXC deployments** +✅ **Resolved in v4.24.0** -If you run Pulse in a container (Docker or LXC), SSH private keys are stored inside the container filesystem. This creates additional security considerations: +### Secure Proxy Architecture (Current) -### Risk +As of v4.24.0, containerized deployments use **pulse-temp-proxy** which eliminates the security concerns: -A compromised Pulse container could expose SSH keys that access your Proxmox hosts, even with forced command restrictions. +- **SSH keys stored on host** - Not accessible from container +- **Unix socket communication** - Pulse never touches SSH keys +- **Automatic during installation** - No manual configuration needed +- **Container compromise = No credential exposure** - Attacker gains nothing -### Opt-In Required (v4.23.1+) +**For new installations:** The proxy is installed automatically during LXC setup. No action required. -Temperature monitoring now requires explicit confirmation during setup. The setup script displays a security notice and asks for your consent before enabling SSH access. +**For existing installations (pre-v4.24.0):** Upgrade your deployment to use the proxy: -### Runtime Warning +```bash +# On your Proxmox host +curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/install-temp-proxy.sh | \ + bash -s -- --ctid +``` -Pulse logs a security warning at startup if it detects: -- Running in a container (Docker/LXC/containerd) -- SSH keys present for temperature monitoring +### Legacy Security Concerns (Pre-v4.24.0) -This reminds you to review security hardening recommendations. +Older versions stored SSH keys inside the container, creating security risks: -### Hardening Recommendations +- Compromised container = exposed SSH keys +- Even with forced commands, keys could be extracted +- Required manual hardening (key rotation, IP restrictions, etc.) + +### Hardening Recommendations (Legacy/Native Installs Only) #### 1. Key Rotation Rotate SSH keys periodically (e.g., every 90 days): @@ -257,31 +287,26 @@ Match User root Address 192.168.1.100 AllowTcpForwarding no ``` -### Future: Agent-Based Architecture +### Verifying Proxy Installation -**Status:** Planned for future release +To check if your deployment is using the secure proxy: -The current SSH approach is a legacy implementation. Future versions will use agent-based monitoring where: +```bash +# On Proxmox host - check proxy service +systemctl status pulse-temp-proxy -- Lightweight temperature agents run on each Proxmox node -- Agents **push** metrics to Pulse over authenticated HTTPS -- No SSH keys stored in Pulse -- Better security boundary between monitoring and infrastructure +# Check if socket exists +ls -l /var/run/pulse-temp-proxy.sock -This is the recommended architecture for production deployments. The SSH method will be maintained as a fallback for simple setups. +# View proxy logs +journalctl -u pulse-temp-proxy -f +``` -### When to Use SSH vs Waiting for Agents - -**SSH Method (Current) - Acceptable for:** -- Home labs and trusted networks -- Non-containerized Pulse deployments -- Environments where you trust the container host - -**Wait for Agents - Better for:** -- Production infrastructure -- Multi-tenant environments -- High-security requirements -- Containerized Pulse with untrusted container hosts +In the Pulse container, check the logs at startup: +```bash +# Should see: "Temperature proxy detected - using secure host-side bridge" +journalctl -u pulse | grep -i proxy +``` ### Disabling Temperature Monitoring diff --git a/install.sh b/install.sh index b34e816..e8de494 100755 --- a/install.sh +++ b/install.sh @@ -1084,7 +1084,42 @@ create_lxc_container() { # Get container IP local IP=$(pct exec $CTID -- hostname -I | awk '{print $1}') - + + # Install temperature proxy on host for secure monitoring + echo + print_info "Installing temperature monitoring proxy on host..." + local proxy_script="/tmp/install-temp-proxy-$$.sh" + + # Download proxy installer + if command -v timeout >/dev/null 2>&1; then + if ! timeout 15 curl -fsSL --connect-timeout 5 --max-time 15 \ + "https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/install-temp-proxy.sh" \ + > "$proxy_script" 2>/dev/null; then + print_warn "Failed to download proxy installer - temperature monitoring unavailable" + print_info "Run manually later: curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/install-temp-proxy.sh | bash -s -- --ctid $CTID" + fi + else + if ! curl -fsSL --connect-timeout 5 --max-time 15 \ + "https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/install-temp-proxy.sh" \ + > "$proxy_script" 2>/dev/null; then + print_warn "Failed to download proxy installer - temperature monitoring unavailable" + print_info "Run manually later: curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/install-temp-proxy.sh | bash -s -- --ctid $CTID" + fi + fi + + # Run proxy installer if downloaded + if [[ -f "$proxy_script" ]]; then + chmod +x "$proxy_script" + if bash "$proxy_script" --ctid "$CTID" 2>&1 | tee /tmp/proxy-install-${CTID}.log; then + print_info "Temperature proxy installed successfully" + else + print_warn "Proxy installation failed - temperature monitoring will use fallback method" + print_info "Check logs: /tmp/proxy-install-${CTID}.log" + print_info "Or run manually: curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/install-temp-proxy.sh | bash -s -- --ctid $CTID" + fi + rm -f "$proxy_script" + fi + # Clean final output echo print_success "Pulse installation complete!" diff --git a/internal/monitoring/temperature.go b/internal/monitoring/temperature.go index 82c4357..6a6616d 100644 --- a/internal/monitoring/temperature.go +++ b/internal/monitoring/temperature.go @@ -10,21 +10,37 @@ import ( "time" "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/rcourtman/pulse-go-rewrite/internal/tempproxy" "github.com/rs/zerolog/log" ) // TemperatureCollector handles SSH-based temperature collection from Proxmox nodes type TemperatureCollector struct { - sshUser string // SSH user (typically "root" or "pulse-monitor") - sshKeyPath string // Path to SSH private key + sshUser string // SSH user (typically "root" or "pulse-monitor") + sshKeyPath string // Path to SSH private key + proxyClient *tempproxy.Client // Optional: unix socket client for proxy + useProxy bool // Whether to use proxy for temperature collection } // NewTemperatureCollector creates a new temperature collector func NewTemperatureCollector(sshUser, sshKeyPath string) *TemperatureCollector { - return &TemperatureCollector{ + tc := &TemperatureCollector{ sshUser: sshUser, sshKeyPath: sshKeyPath, } + + // Check if proxy is available + proxyClient := tempproxy.NewClient() + if proxyClient.IsAvailable() { + log.Info().Msg("Temperature proxy detected - using secure host-side bridge") + tc.proxyClient = proxyClient + tc.useProxy = true + } else { + log.Debug().Msg("Temperature proxy not available - using direct SSH") + tc.useProxy = false + } + + return tc } // CollectTemperature collects temperature data from a node via SSH @@ -32,15 +48,31 @@ func (tc *TemperatureCollector) CollectTemperature(ctx context.Context, nodeHost // Extract hostname/IP from the host URL (might be https://hostname:8006) host := extractHostname(nodeHost) - // Try to get sensors JSON output - output, err := tc.runSSHCommand(ctx, host, "sensors -j 2>/dev/null") - if err != nil { - log.Debug(). - Str("node", nodeName). - Str("host", host). - Err(err). - Msg("Failed to collect temperature data via SSH") - return &models.Temperature{Available: false}, nil + var output string + var err error + + // Use proxy if available, otherwise fall back to direct SSH + if tc.useProxy && tc.proxyClient != nil { + output, err = tc.proxyClient.GetTemperature(host) + if err != nil { + log.Debug(). + Str("node", nodeName). + Str("host", host). + Err(err). + Msg("Failed to collect temperature data via proxy") + return &models.Temperature{Available: false}, nil + } + } else { + // Direct SSH (legacy method) + output, err = tc.runSSHCommand(ctx, host, "sensors -j 2>/dev/null") + if err != nil { + log.Debug(). + Str("node", nodeName). + Str("host", host). + Err(err). + Msg("Failed to collect temperature data via SSH") + return &models.Temperature{Available: false}, nil + } } // Parse sensors JSON output diff --git a/internal/tempproxy/client.go b/internal/tempproxy/client.go new file mode 100644 index 0000000..51f2317 --- /dev/null +++ b/internal/tempproxy/client.go @@ -0,0 +1,181 @@ +package tempproxy + +import ( + "encoding/json" + "fmt" + "net" + "os" + "time" + + "github.com/rs/zerolog/log" +) + +const ( + defaultSocketPath = "/var/run/pulse-temp-proxy.sock" + defaultTimeout = 10 * time.Second +) + +// Client communicates with pulse-temp-proxy via unix socket +type Client struct { + socketPath string + timeout time.Duration +} + +// NewClient creates a new proxy client +func NewClient() *Client { + socketPath := os.Getenv("PULSE_TEMP_PROXY_SOCKET") + if socketPath == "" { + socketPath = defaultSocketPath + } + + return &Client{ + socketPath: socketPath, + timeout: defaultTimeout, + } +} + +// IsAvailable checks if the proxy is running and accessible +func (c *Client) IsAvailable() bool { + _, err := os.Stat(c.socketPath) + return err == nil +} + +// RPCRequest represents a request to the proxy +type RPCRequest struct { + Method string `json:"method"` + Params map[string]interface{} `json:"params"` +} + +// RPCResponse represents a response from the proxy +type RPCResponse struct { + Success bool `json:"success"` + Data map[string]interface{} `json:"data,omitempty"` + Error string `json:"error,omitempty"` +} + +// call sends an RPC request and returns the response +func (c *Client) call(method string, params map[string]interface{}) (*RPCResponse, error) { + // Connect to unix socket + conn, err := net.DialTimeout("unix", c.socketPath, c.timeout) + if err != nil { + return nil, fmt.Errorf("failed to connect to proxy: %w", err) + } + defer conn.Close() + + // Set deadline + conn.SetDeadline(time.Now().Add(c.timeout)) + + // Send request + req := RPCRequest{ + Method: method, + Params: params, + } + + encoder := json.NewEncoder(conn) + if err := encoder.Encode(req); err != nil { + return nil, fmt.Errorf("failed to encode request: %w", err) + } + + // Read response + var resp RPCResponse + decoder := json.NewDecoder(conn) + if err := decoder.Decode(&resp); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &resp, nil +} + +// GetStatus returns proxy status +func (c *Client) GetStatus() (map[string]interface{}, error) { + resp, err := c.call("get_status", nil) + if err != nil { + return nil, err + } + + if !resp.Success { + return nil, fmt.Errorf("proxy error: %s", resp.Error) + } + + return resp.Data, nil +} + +// EnsureClusterKeys discovers cluster nodes and pushes SSH keys +func (c *Client) EnsureClusterKeys() (map[string]interface{}, error) { + log.Info().Msg("Requesting proxy to configure cluster SSH keys") + + resp, err := c.call("ensure_cluster_keys", nil) + if err != nil { + return nil, err + } + + if !resp.Success { + return nil, fmt.Errorf("proxy error: %s", resp.Error) + } + + return resp.Data, nil +} + +// RegisterNodes returns list of discovered nodes with SSH status +func (c *Client) RegisterNodes() ([]map[string]interface{}, error) { + resp, err := c.call("register_nodes", nil) + if err != nil { + return nil, err + } + + if !resp.Success { + return nil, fmt.Errorf("proxy error: %s", resp.Error) + } + + // Extract nodes array from data + nodesRaw, ok := resp.Data["nodes"] + if !ok { + return nil, fmt.Errorf("no nodes in response") + } + + // Type assertion to []interface{} first, then convert + nodesArray, ok := nodesRaw.([]interface{}) + if !ok { + return nil, fmt.Errorf("nodes is not an array") + } + + nodes := make([]map[string]interface{}, len(nodesArray)) + for i, nodeRaw := range nodesArray { + node, ok := nodeRaw.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("node %d is not a map", i) + } + nodes[i] = node + } + + return nodes, nil +} + +// GetTemperature fetches temperature data from a specific node +func (c *Client) GetTemperature(nodeHost string) (string, error) { + params := map[string]interface{}{ + "node": nodeHost, + } + + resp, err := c.call("get_temperature", params) + if err != nil { + return "", err + } + + if !resp.Success { + return "", fmt.Errorf("proxy error: %s", resp.Error) + } + + // Extract temperature JSON string + tempRaw, ok := resp.Data["temperature"] + if !ok { + return "", fmt.Errorf("no temperature data in response") + } + + tempStr, ok := tempRaw.(string) + if !ok { + return "", fmt.Errorf("temperature is not a string") + } + + return tempStr, nil +} diff --git a/scripts/install-temp-proxy.sh b/scripts/install-temp-proxy.sh new file mode 100755 index 0000000..a951f1e --- /dev/null +++ b/scripts/install-temp-proxy.sh @@ -0,0 +1,227 @@ +#!/bin/bash + +# install-temp-proxy.sh - Installs pulse-temp-proxy on Proxmox host for secure temperature monitoring +# This script is idempotent and can be safely re-run + +set -euo pipefail + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +print_info() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +print_warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Check if running on Proxmox host +if ! command -v pvecm >/dev/null 2>&1; then + print_error "This script must be run on a Proxmox VE host" + exit 1 +fi + +# Parse arguments +CTID="" +VERSION="latest" + +while [[ $# -gt 0 ]]; do + case $1 in + --ctid) + CTID="$2" + shift 2 + ;; + --version) + VERSION="$2" + shift 2 + ;; + *) + print_error "Unknown option: $1" + exit 1 + ;; + esac +done + +if [[ -z "$CTID" ]]; then + print_error "Missing required argument: --ctid " + echo "Usage: $0 --ctid [--version ]" + exit 1 +fi + +# Verify container exists +if ! pct status "$CTID" >/dev/null 2>&1; then + print_error "Container $CTID does not exist" + exit 1 +fi + +print_info "Installing pulse-temp-proxy for container $CTID" + +# Determine download URL +GITHUB_REPO="rcourtman/Pulse" +if [[ "$VERSION" == "latest" ]]; then + RELEASE_URL="https://api.github.com/repos/$GITHUB_REPO/releases/latest" + print_info "Fetching latest release info..." + RELEASE_DATA=$(curl -fsSL "$RELEASE_URL") + VERSION=$(echo "$RELEASE_DATA" | grep -o '"tag_name": "[^"]*"' | cut -d'"' -f4) + if [[ -z "$VERSION" ]]; then + print_error "Failed to determine latest version" + exit 1 + fi + print_info "Latest version: $VERSION" +fi + +# Detect architecture +ARCH=$(uname -m) +case $ARCH in + x86_64) + BINARY_NAME="pulse-temp-proxy-linux-amd64" + ;; + aarch64|arm64) + BINARY_NAME="pulse-temp-proxy-linux-arm64" + ;; + armv7l|armhf) + BINARY_NAME="pulse-temp-proxy-linux-armv7" + ;; + *) + print_error "Unsupported architecture: $ARCH" + exit 1 + ;; +esac + +DOWNLOAD_URL="https://github.com/$GITHUB_REPO/releases/download/$VERSION/$BINARY_NAME" +BINARY_PATH="/usr/local/bin/pulse-temp-proxy" +SERVICE_PATH="/etc/systemd/system/pulse-temp-proxy.service" +SOCKET_PATH="/var/run/pulse-temp-proxy.sock" +SSH_DIR="/var/lib/pulse-temp-proxy/ssh" + +# Download binary +print_info "Downloading $BINARY_NAME..." +if ! curl -fsSL "$DOWNLOAD_URL" -o "$BINARY_PATH.tmp"; then + print_error "Failed to download binary from $DOWNLOAD_URL" + exit 1 +fi + +# Make executable and move to final location +chmod +x "$BINARY_PATH.tmp" +mv "$BINARY_PATH.tmp" "$BINARY_PATH" +print_info "Binary installed to $BINARY_PATH" + +# Create SSH key directory +mkdir -p "$SSH_DIR" +chmod 700 "$SSH_DIR" + +# Install systemd service +print_info "Installing systemd service..." +cat > "$SERVICE_PATH" << 'EOF' +[Unit] +Description=Pulse Temperature Proxy +Documentation=https://github.com/rcourtman/Pulse +After=network.target + +[Service] +Type=simple +User=root +ExecStart=/usr/local/bin/pulse-temp-proxy +Restart=on-failure +RestartSec=5s + +# Security hardening +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/lib/pulse-temp-proxy /var/run + +# Logging +StandardOutput=journal +StandardError=journal +SyslogIdentifier=pulse-temp-proxy + +[Install] +WantedBy=multi-user.target +EOF + +# Reload systemd and start service +print_info "Enabling and starting service..." +systemctl daemon-reload +systemctl enable pulse-temp-proxy.service +systemctl restart pulse-temp-proxy.service + +# Wait for socket to appear +print_info "Waiting for socket..." +for i in {1..10}; do + if [[ -S "$SOCKET_PATH" ]]; then + break + fi + sleep 1 +done + +if [[ ! -S "$SOCKET_PATH" ]]; then + print_error "Socket did not appear after 10 seconds" + print_info "Check service status: systemctl status pulse-temp-proxy" + exit 1 +fi + +print_info "Socket ready at $SOCKET_PATH" + +# Configure LXC bind mount +LXC_CONFIG="/etc/pve/lxc/${CTID}.conf" +BIND_ENTRY="lxc.mount.entry: /var/run/pulse-temp-proxy.sock var/run/pulse-temp-proxy.sock none bind,create=file 0 0" + +# Check if bind mount already exists +if grep -q "pulse-temp-proxy.sock" "$LXC_CONFIG"; then + print_info "Bind mount already configured in LXC config" +else + print_info "Adding bind mount to LXC config..." + echo "$BIND_ENTRY" >> "$LXC_CONFIG" + + # Restart container to apply bind mount + print_info "Restarting container to apply bind mount..." + pct stop "$CTID" || true + sleep 2 + pct start "$CTID" + sleep 3 +fi + +# Verify socket is accessible in container +print_info "Verifying socket accessibility..." +if pct exec "$CTID" -- test -S /var/run/pulse-temp-proxy.sock; then + print_info "Socket is accessible in container" +else + print_warn "Socket is not yet accessible in container" + print_info "Container may need additional restart or configuration" +fi + +# Test proxy status +print_info "Testing proxy status..." +if systemctl is-active --quiet pulse-temp-proxy; then + print_info "${GREEN}✓${NC} pulse-temp-proxy is running" +else + print_error "pulse-temp-proxy is not running" + print_info "Check logs: journalctl -u pulse-temp-proxy -n 50" + exit 1 +fi + +print_info "${GREEN}Installation complete!${NC}" +print_info "" +print_info "Temperature monitoring will now use the secure host-side proxy" +print_info "SSH keys are stored on the Proxmox host, not in the container" +print_info "" +print_info "To configure temperature monitoring for cluster nodes:" +print_info " 1. Access Pulse UI in container $CTID" +print_info " 2. Go to Settings → Enable Temperature Monitoring" +print_info " 3. The proxy will automatically discover and configure cluster nodes" +print_info "" +print_info "To check proxy status:" +print_info " systemctl status pulse-temp-proxy" +print_info " journalctl -u pulse-temp-proxy -f" + +exit 0 diff --git a/scripts/pulse-temp-proxy.service b/scripts/pulse-temp-proxy.service new file mode 100644 index 0000000..9ec2644 --- /dev/null +++ b/scripts/pulse-temp-proxy.service @@ -0,0 +1,26 @@ +[Unit] +Description=Pulse Temperature Proxy +Documentation=https://github.com/rcourtman/Pulse +After=network.target + +[Service] +Type=simple +User=root +ExecStart=/usr/local/bin/pulse-temp-proxy +Restart=on-failure +RestartSec=5s + +# Security hardening +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/lib/pulse-temp-proxy /var/run + +# Logging +StandardOutput=journal +StandardError=journal +SyslogIdentifier=pulse-temp-proxy + +[Install] +WantedBy=multi-user.target