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
|
||||
|
||||
Temperature monitoring uses standard SSH key authentication (just like Ansible, Saltstack, and other automation tools) to securely collect sensor data from your nodes. Pulse connects via SSH and runs the `sensors` command to read hardware temperatures - that's it!
|
||||
### Secure Architecture (v4.24.0+)
|
||||
|
||||
> **Important:** Run every setup command as the same user account that executes the Pulse service (typically `pulse`). The backend reads the SSH key from that user’s home directory; keys under `root` or other accounts will be ignored.
|
||||
For **containerized deployments** (LXC/Docker), Pulse uses a secure proxy architecture:
|
||||
|
||||
1. **pulse-temp-proxy** runs on the Proxmox host (outside the container)
|
||||
2. SSH keys are stored on the host filesystem (`/var/lib/pulse-temp-proxy/ssh/`)
|
||||
3. Pulse communicates with the proxy via unix socket
|
||||
4. The proxy handles all SSH connections to cluster nodes
|
||||
|
||||
**Benefits:**
|
||||
- SSH keys never enter the container
|
||||
- Container compromise doesn't expose infrastructure credentials
|
||||
- Automatically configured during installation
|
||||
- Transparent to users - no setup changes
|
||||
|
||||
### Legacy Architecture (Pre-v4.24.0 / Native Installs)
|
||||
|
||||
For native (non-containerized) installations, Pulse connects directly via SSH:
|
||||
|
||||
1. Pulse uses SSH key authentication (like Ansible, Terraform, etc.)
|
||||
2. Runs `sensors -j` command to read hardware temperatures
|
||||
3. SSH key stored in Pulse's home directory
|
||||
|
||||
> **Important for native installs:** Run every setup command as the same user account that executes the Pulse service (typically `pulse`). The backend reads the SSH key from that user's home directory.
|
||||
|
||||
## Requirements
|
||||
|
||||
|
|
@ -176,27 +197,36 @@ You can still manage the entry manually if you prefer, but no extra steps are re
|
|||
|
||||
## Container Security Considerations
|
||||
|
||||
⚠️ **Important for Docker/LXC deployments**
|
||||
✅ **Resolved in v4.24.0**
|
||||
|
||||
If you run Pulse in a container (Docker or LXC), SSH private keys are stored inside the container filesystem. This creates additional security considerations:
|
||||
### Secure Proxy Architecture (Current)
|
||||
|
||||
### Risk
|
||||
As of v4.24.0, containerized deployments use **pulse-temp-proxy** which eliminates the security concerns:
|
||||
|
||||
A compromised Pulse container could expose SSH keys that access your Proxmox hosts, even with forced command restrictions.
|
||||
- **SSH keys stored on host** - Not accessible from container
|
||||
- **Unix socket communication** - Pulse never touches SSH keys
|
||||
- **Automatic during installation** - No manual configuration needed
|
||||
- **Container compromise = No credential exposure** - Attacker gains nothing
|
||||
|
||||
### Opt-In Required (v4.23.1+)
|
||||
**For new installations:** The proxy is installed automatically during LXC setup. No action required.
|
||||
|
||||
Temperature monitoring now requires explicit confirmation during setup. The setup script displays a security notice and asks for your consent before enabling SSH access.
|
||||
**For existing installations (pre-v4.24.0):** Upgrade your deployment to use the proxy:
|
||||
|
||||
### Runtime Warning
|
||||
```bash
|
||||
# On your Proxmox host
|
||||
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/install-temp-proxy.sh | \
|
||||
bash -s -- --ctid <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
|
||||
|
||||
|
|
|
|||
37
install.sh
37
install.sh
|
|
@ -1084,7 +1084,42 @@ create_lxc_container() {
|
|||
|
||||
# Get container IP
|
||||
local IP=$(pct exec $CTID -- hostname -I | awk '{print $1}')
|
||||
|
||||
|
||||
# Install temperature proxy on host for secure monitoring
|
||||
echo
|
||||
print_info "Installing temperature monitoring proxy on host..."
|
||||
local proxy_script="/tmp/install-temp-proxy-$$.sh"
|
||||
|
||||
# Download proxy installer
|
||||
if command -v timeout >/dev/null 2>&1; then
|
||||
if ! timeout 15 curl -fsSL --connect-timeout 5 --max-time 15 \
|
||||
"https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/install-temp-proxy.sh" \
|
||||
> "$proxy_script" 2>/dev/null; then
|
||||
print_warn "Failed to download proxy installer - temperature monitoring unavailable"
|
||||
print_info "Run manually later: curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/install-temp-proxy.sh | bash -s -- --ctid $CTID"
|
||||
fi
|
||||
else
|
||||
if ! curl -fsSL --connect-timeout 5 --max-time 15 \
|
||||
"https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/install-temp-proxy.sh" \
|
||||
> "$proxy_script" 2>/dev/null; then
|
||||
print_warn "Failed to download proxy installer - temperature monitoring unavailable"
|
||||
print_info "Run manually later: curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/install-temp-proxy.sh | bash -s -- --ctid $CTID"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Run proxy installer if downloaded
|
||||
if [[ -f "$proxy_script" ]]; then
|
||||
chmod +x "$proxy_script"
|
||||
if bash "$proxy_script" --ctid "$CTID" 2>&1 | tee /tmp/proxy-install-${CTID}.log; then
|
||||
print_info "Temperature proxy installed successfully"
|
||||
else
|
||||
print_warn "Proxy installation failed - temperature monitoring will use fallback method"
|
||||
print_info "Check logs: /tmp/proxy-install-${CTID}.log"
|
||||
print_info "Or run manually: curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/install-temp-proxy.sh | bash -s -- --ctid $CTID"
|
||||
fi
|
||||
rm -f "$proxy_script"
|
||||
fi
|
||||
|
||||
# Clean final output
|
||||
echo
|
||||
print_success "Pulse installation complete!"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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