Addresses security concern raised in code review: - Socket permissions changed from 0666 to 0660 - Added SO_PEERCRED verification to authenticate connecting processes - Only allows root (UID 0) or proxy's own user - Prevents unauthorized processes from triggering SSH key rollout - Documented passwordless root SSH requirement for clusters This prevents any process on the host or in other containers from accessing the proxy RPC endpoints.
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 = "/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 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,
|
|
},
|
|
}
|
|
}
|