diff --git a/cmd/pulse-sensor-proxy/main.go b/cmd/pulse-sensor-proxy/main.go index c04c049..cdc80cf 100644 --- a/cmd/pulse-sensor-proxy/main.go +++ b/cmd/pulse-sensor-proxy/main.go @@ -39,7 +39,7 @@ const ( defaultConfigPath = "/etc/pulse-sensor-proxy/config.yaml" defaultAuditLogPath = "/var/log/pulse/sensor-proxy/audit.log" maxRequestBytes = 16 * 1024 // 16 KiB max request size - defaultRunAsUser = "pulse-sensor" + defaultRunAsUser = "pulse-sensor-proxy" ) func defaultWorkDir() string { diff --git a/cmd/pulse-sensor-proxy/ssh.go b/cmd/pulse-sensor-proxy/ssh.go index 76dfe34..4a4a10b 100644 --- a/cmd/pulse-sensor-proxy/ssh.go +++ b/cmd/pulse-sensor-proxy/ssh.go @@ -557,6 +557,7 @@ func (p *Proxy) getTemperatureViaSSH(nodeHost string) (string, error) { // discoverClusterNodes discovers all nodes in the Proxmox cluster // Returns IP addresses of cluster nodes +// For standalone nodes (no cluster), returns the host's own addresses func discoverClusterNodes() ([]string, error) { // Check if pvecm is available (only on Proxmox hosts) if _, err := exec.LookPath("pvecm"); err != nil { @@ -568,9 +569,21 @@ func discoverClusterNodes() ([]string, error) { var out, stderr bytes.Buffer cmd.Stdout = &out cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - log.Warn().Str("stderr", stderr.String()).Msg("pvecm status failed") - return nil, fmt.Errorf("failed to get cluster status: %w (stderr: %s)", err, stderr.String()) + err := cmd.Run() + + // pvecm status exits with code 2 on standalone nodes (not in a cluster) + // Treat this as a valid case and discover local host addresses + if err != nil { + stderrStr := stderr.String() + // Check if this is the "not part of a cluster" error + if strings.Contains(stderrStr, "does not exist") || + strings.Contains(stderrStr, "not part of a cluster") { + log.Info().Msg("Standalone Proxmox node detected (not in cluster) - discovering local host addresses") + return discoverLocalHostAddresses() + } + // For other errors, fail + log.Warn().Str("stderr", stderrStr).Msg("pvecm status failed") + return nil, fmt.Errorf("failed to get cluster status: %w (stderr: %s)", err, stderrStr) } // Parse output to extract IP addresses @@ -607,6 +620,81 @@ func discoverClusterNodes() ([]string, error) { return nodes, nil } +// discoverLocalHostAddresses discovers all addresses of the local host +// Used for standalone nodes that aren't part of a cluster +func discoverLocalHostAddresses() ([]string, error) { + addresses := make(map[string]struct{}) + + // Get hostname and FQDN + if hostname, err := os.Hostname(); err == nil && hostname != "" { + addresses[strings.ToLower(hostname)] = struct{}{} + + // Try to get FQDN + cmd := exec.Command("hostname", "-f") + if out, err := cmd.Output(); err == nil { + fqdn := strings.TrimSpace(string(out)) + if fqdn != "" && fqdn != hostname { + addresses[strings.ToLower(fqdn)] = struct{}{} + } + } + } + + // Get all non-loopback IP addresses using ip command + cmd := exec.Command("ip", "-o", "addr", "show") + out, err := cmd.Output() + if err != nil { + log.Warn().Err(err).Msg("Failed to get IP addresses via 'ip addr'") + } else { + // Parse output lines like: + // 2: eth0 inet 192.168.0.100/24 brd 192.168.0.255 scope global eth0 + // 2: eth0 inet6 fe80::a00:27ff:fe4e:66a1/64 scope link + lines := strings.Split(string(out), "\n") + for _, line := range lines { + fields := strings.Fields(line) + if len(fields) < 4 { + continue + } + + // Field 2 is interface, field 3 is inet/inet6, field 4 is addr/prefix + if fields[2] != "inet" && fields[2] != "inet6" { + continue + } + + // Split addr/prefix (e.g., "192.168.0.100/24") + addrWithPrefix := fields[3] + addr := strings.Split(addrWithPrefix, "/")[0] + + // Skip loopback addresses + if strings.HasPrefix(addr, "127.") || addr == "::1" { + continue + } + + // Skip link-local IPv6 + if strings.HasPrefix(addr, "fe80:") { + continue + } + + addresses[addr] = struct{}{} + } + } + + // Convert map to slice + result := make([]string, 0, len(addresses)) + for addr := range addresses { + result = append(result, addr) + } + + if len(result) == 0 { + return nil, fmt.Errorf("no local host addresses found") + } + + log.Info(). + Strs("addresses", result). + Msg("Discovered local host addresses for standalone node validation") + + return result, nil +} + // isProxmoxHost checks if we're running on a Proxmox host func isProxmoxHost() bool { // Check for pvecm command diff --git a/cmd/pulse-sensor-proxy/ssh_test.go b/cmd/pulse-sensor-proxy/ssh_test.go index c6a2227..d4c31e9 100644 --- a/cmd/pulse-sensor-proxy/ssh_test.go +++ b/cmd/pulse-sensor-proxy/ssh_test.go @@ -172,3 +172,34 @@ func TestReadAllWithLimit(t *testing.T) { t.Fatalf("expected unlimited read to return full data without exceeding") } } + +func TestDiscoverLocalHostAddresses(t *testing.T) { + // This test verifies that discoverLocalHostAddresses returns valid addresses + // It will vary by host but should always return at least hostname or IP addresses + addresses, err := discoverLocalHostAddresses() + if err != nil { + t.Fatalf("discoverLocalHostAddresses failed: %v", err) + } + + if len(addresses) == 0 { + t.Fatal("expected at least one address from discoverLocalHostAddresses") + } + + // Verify addresses are non-empty and don't contain loopback + for _, addr := range addresses { + if addr == "" { + t.Error("got empty address in results") + } + if addr == "127.0.0.1" || addr == "::1" { + t.Errorf("discoverLocalHostAddresses should not return loopback address: %s", addr) + } + if strings.HasPrefix(addr, "127.") { + t.Errorf("discoverLocalHostAddresses should not return loopback range: %s", addr) + } + if strings.HasPrefix(addr, "fe80:") { + t.Errorf("discoverLocalHostAddresses should not return link-local IPv6: %s", addr) + } + } + + t.Logf("Discovered %d local addresses: %v", len(addresses), addresses) +} diff --git a/install.sh b/install.sh index 6e0dcd3..28ede51 100755 --- a/install.sh +++ b/install.sh @@ -1427,9 +1427,21 @@ fi'; then # Clean up temporary binary if it was copied [[ -f "$local_proxy_binary" ]] && rm -f "$local_proxy_binary" 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-sensor-proxy.sh | bash -s -- --ctid $CTID" + # Proxy installation failed - this is a fatal error since user opted in + rm -f "$proxy_script" + echo + print_error "Temperature proxy installation failed" + echo + echo "Temperature monitoring was enabled but the proxy setup failed." + echo "Check logs: /tmp/proxy-install-${CTID}.log" + echo + echo "To fix, you can either:" + echo " 1. Fix the issue and run the proxy installer manually:" + echo " curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/install-sensor-proxy.sh | bash -s -- --ctid $CTID" + echo + echo " 2. Re-run the Pulse installer and skip temperature monitoring when prompted" + echo + exit 1 fi rm -f "$proxy_script" fi