Pulse/cmd/pulse-temp-proxy/ssh.go
rcourtman e7bc338891 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
2025-10-12 21:35:35 +00:00

161 lines
4.4 KiB
Go

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
}