mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 11:30:15 +00:00
Fixes LXC bind mount issue where socket-level mounts break when the socket is recreated by systemd. Following Codex's recommendation to bind mount the directory instead of the file. Changes: - Socket path: /run/pulse-temp-proxy/pulse-temp-proxy.sock - Systemd: RuntimeDirectory=pulse-temp-proxy (auto-creates /run/pulse-temp-proxy) - Systemd: RuntimeDirectoryMode=0770 for group access - LXC mount: Bind entire /run/pulse-temp-proxy directory - Install script: Upgrades old socket-level mounts to directory-level - Install script: Detects and handles bind mount changes This survives socket recreations and container restarts. The directory mount persists even when systemd unlinks/recreates the socket file. Related to #528
434 lines
10 KiB
Go
434 lines
10 KiB
Go
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 = "/run/pulse-temp-proxy/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 to owner+group only
|
|
// We use SO_PEERCRED for authentication, so we don't need world-readable
|
|
if err := os.Chmod(p.socketPath, 0660); 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()
|
|
|
|
// Verify peer credentials (SO_PEERCRED authentication)
|
|
if err := verifyPeerCredentials(conn); err != nil {
|
|
log.Warn().Err(err).Msg("Unauthorized connection attempt")
|
|
p.sendError(conn, "unauthorized")
|
|
return
|
|
}
|
|
|
|
// 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,
|
|
},
|
|
}
|
|
}
|