feat: Implement secure temperature proxy for containerized deployments
Addresses #528 Introduces pulse-temp-proxy architecture to eliminate SSH key exposure in containers: **Architecture:** - pulse-temp-proxy runs on Proxmox host (outside LXC/Docker) - SSH keys stored on host filesystem (/var/lib/pulse-temp-proxy/ssh/) - Pulse communicates via unix socket (bind-mounted into container) - Proxy handles cluster discovery, key rollout, and temperature fetching **Components:** - cmd/pulse-temp-proxy: Standalone Go binary with unix socket RPC server - internal/tempproxy: Client library for Pulse backend - scripts/install-temp-proxy.sh: Idempotent installer for existing deployments - scripts/pulse-temp-proxy.service: Systemd service for proxy **Integration:** - Pulse automatically detects and uses proxy when socket exists - Falls back to direct SSH for native installations - Installer automatically configures proxy for new LXC deployments - Existing LXC users can upgrade by running install-temp-proxy.sh **Security improvements:** - Container compromise no longer exposes SSH keys - SSH keys never enter container filesystem - Maintains forced command restrictions - Transparent to users - no workflow changes **Documentation:** - Updated TEMPERATURE_MONITORING.md with new architecture - Added verification steps and upgrade instructions - Preserved legacy documentation for native installs
This commit is contained in:
parent
c8e3c93516
commit
e7bc338891
8 changed files with 1160 additions and 47 deletions
426
cmd/pulse-temp-proxy/main.go
Normal file
426
cmd/pulse-temp-proxy/main.go
Normal file
|
|
@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
161
cmd/pulse-temp-proxy/ssh.go
Normal file
161
cmd/pulse-temp-proxy/ssh.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -14,9 +14,30 @@ Pulse can display real-time CPU and NVMe temperatures directly in your dashboard
|
||||||
|
|
||||||
## How It Works
|
## 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
|
## Requirements
|
||||||
|
|
||||||
|
|
@ -176,27 +197,36 @@ You can still manage the entry manually if you prefer, but no extra steps are re
|
||||||
|
|
||||||
## Container Security Considerations
|
## 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 <your-pulse-container-id>
|
||||||
|
```
|
||||||
|
|
||||||
Pulse logs a security warning at startup if it detects:
|
### Legacy Security Concerns (Pre-v4.24.0)
|
||||||
- Running in a container (Docker/LXC/containerd)
|
|
||||||
- SSH keys present for temperature monitoring
|
|
||||||
|
|
||||||
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
|
#### 1. Key Rotation
|
||||||
Rotate SSH keys periodically (e.g., every 90 days):
|
Rotate SSH keys periodically (e.g., every 90 days):
|
||||||
|
|
@ -257,31 +287,26 @@ Match User root Address 192.168.1.100
|
||||||
AllowTcpForwarding no
|
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
|
# Check if socket exists
|
||||||
- Agents **push** metrics to Pulse over authenticated HTTPS
|
ls -l /var/run/pulse-temp-proxy.sock
|
||||||
- No SSH keys stored in Pulse
|
|
||||||
- Better security boundary between monitoring and infrastructure
|
|
||||||
|
|
||||||
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
|
In the Pulse container, check the logs at startup:
|
||||||
|
```bash
|
||||||
**SSH Method (Current) - Acceptable for:**
|
# Should see: "Temperature proxy detected - using secure host-side bridge"
|
||||||
- Home labs and trusted networks
|
journalctl -u pulse | grep -i proxy
|
||||||
- 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
|
|
||||||
|
|
||||||
### Disabling Temperature Monitoring
|
### Disabling Temperature Monitoring
|
||||||
|
|
||||||
|
|
|
||||||
37
install.sh
37
install.sh
|
|
@ -1084,7 +1084,42 @@ create_lxc_container() {
|
||||||
|
|
||||||
# Get container IP
|
# Get container IP
|
||||||
local IP=$(pct exec $CTID -- hostname -I | awk '{print $1}')
|
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
|
# Clean final output
|
||||||
echo
|
echo
|
||||||
print_success "Pulse installation complete!"
|
print_success "Pulse installation complete!"
|
||||||
|
|
|
||||||
|
|
@ -10,21 +10,37 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||||
|
"github.com/rcourtman/pulse-go-rewrite/internal/tempproxy"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TemperatureCollector handles SSH-based temperature collection from Proxmox nodes
|
// TemperatureCollector handles SSH-based temperature collection from Proxmox nodes
|
||||||
type TemperatureCollector struct {
|
type TemperatureCollector struct {
|
||||||
sshUser string // SSH user (typically "root" or "pulse-monitor")
|
sshUser string // SSH user (typically "root" or "pulse-monitor")
|
||||||
sshKeyPath string // Path to SSH private key
|
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
|
// NewTemperatureCollector creates a new temperature collector
|
||||||
func NewTemperatureCollector(sshUser, sshKeyPath string) *TemperatureCollector {
|
func NewTemperatureCollector(sshUser, sshKeyPath string) *TemperatureCollector {
|
||||||
return &TemperatureCollector{
|
tc := &TemperatureCollector{
|
||||||
sshUser: sshUser,
|
sshUser: sshUser,
|
||||||
sshKeyPath: sshKeyPath,
|
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
|
// 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)
|
// Extract hostname/IP from the host URL (might be https://hostname:8006)
|
||||||
host := extractHostname(nodeHost)
|
host := extractHostname(nodeHost)
|
||||||
|
|
||||||
// Try to get sensors JSON output
|
var output string
|
||||||
output, err := tc.runSSHCommand(ctx, host, "sensors -j 2>/dev/null")
|
var err error
|
||||||
if err != nil {
|
|
||||||
log.Debug().
|
// Use proxy if available, otherwise fall back to direct SSH
|
||||||
Str("node", nodeName).
|
if tc.useProxy && tc.proxyClient != nil {
|
||||||
Str("host", host).
|
output, err = tc.proxyClient.GetTemperature(host)
|
||||||
Err(err).
|
if err != nil {
|
||||||
Msg("Failed to collect temperature data via SSH")
|
log.Debug().
|
||||||
return &models.Temperature{Available: false}, nil
|
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
|
// Parse sensors JSON output
|
||||||
|
|
|
||||||
181
internal/tempproxy/client.go
Normal file
181
internal/tempproxy/client.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
227
scripts/install-temp-proxy.sh
Executable file
227
scripts/install-temp-proxy.sh
Executable file
|
|
@ -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 <container-id>"
|
||||||
|
echo "Usage: $0 --ctid <container-id> [--version <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
|
||||||
26
scripts/pulse-temp-proxy.service
Normal file
26
scripts/pulse-temp-proxy.service
Normal file
|
|
@ -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
|
||||||
Loading…
Reference in a new issue