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:
rcourtman 2025-10-12 21:35:35 +00:00
parent c8e3c93516
commit e7bc338891
8 changed files with 1160 additions and 47 deletions

View 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
View 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
}

View file

@ -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 users 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 <your-pulse-container-id>
```
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

View file

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

View file

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

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

View 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