Pulse/cmd/pulse-sensor-proxy/config.go
rcourtman 2ee693cc63 Add HTTP mode to pulse-sensor-proxy for multi-instance temperature monitoring
This implements HTTP/HTTPS support for pulse-sensor-proxy to enable
temperature monitoring across multiple separate Proxmox instances.

Architecture changes:
- Dual-mode operation: Unix socket (local) + HTTPS (remote)
- Unix socket remains default for security/performance (no breaking change)
- HTTP mode enables temps from external PVE hosts

Backend implementation:
- Add HTTPS server with TLS + Bearer token authentication to sensor-proxy
- Add TemperatureProxyURL and TemperatureProxyToken fields to PVEInstance
- Add HTTP client (internal/tempproxy/http_client.go) for remote proxy calls
- Update temperature collector to prefer HTTP proxy when configured
- Fallback logic: HTTP proxy → Unix socket → direct SSH (if not containerized)

Configuration:
- pulse-sensor-proxy config: http_enabled, http_listen_addr, http_tls_cert/key, http_auth_token
- PVEInstance config: temperature_proxy_url, temperature_proxy_token
- Environment variables: PULSE_SENSOR_PROXY_HTTP_* for all HTTP settings

Security:
- TLS 1.2+ with modern cipher suites
- Constant-time token comparison (timing attack prevention)
- Rate limiting applied to HTTP requests (shared with socket mode)
- Audit logging for all HTTP requests

Next steps:
- Update installer script to support HTTP mode + auto-registration
- Add Pulse API endpoint for proxy registration
- Generate TLS certificates during installation
- Test multi-instance temperature collection

Related to #571 (multi-instance architecture)
2025-11-13 16:13:53 +00:00

455 lines
15 KiB
Go

package main
import (
"fmt"
"net"
"os"
"strconv"
"strings"
"time"
"github.com/rs/zerolog/log"
"gopkg.in/yaml.v3"
)
// RateLimitConfig holds rate limiting configuration
type RateLimitConfig struct {
PerPeerIntervalMs int `yaml:"per_peer_interval_ms"` // Milliseconds between requests per peer
PerPeerBurst int `yaml:"per_peer_burst"` // Number of requests allowed in a burst
}
// Config holds proxy configuration
type Config struct {
AllowedSourceSubnets []string `yaml:"allowed_source_subnets"`
MetricsAddress string `yaml:"metrics_address"`
LogLevel string `yaml:"log_level"`
AllowedNodes []string `yaml:"allowed_nodes"`
StrictNodeValidation bool `yaml:"strict_node_validation"`
ReadTimeout time.Duration `yaml:"read_timeout"`
WriteTimeout time.Duration `yaml:"write_timeout"`
MaxSSHOutputBytes int64 `yaml:"max_ssh_output_bytes"`
RequireProxmoxHostkeys bool `yaml:"require_proxmox_hostkeys"`
AllowedPeers []PeerConfig `yaml:"allowed_peers"`
AllowIDMappedRoot bool `yaml:"allow_idmapped_root"`
AllowedPeerUIDs []uint32 `yaml:"allowed_peer_uids"`
AllowedPeerGIDs []uint32 `yaml:"allowed_peer_gids"`
AllowedIDMapUsers []string `yaml:"allowed_idmap_users"`
RateLimit *RateLimitConfig `yaml:"rate_limit,omitempty"`
// HTTP mode configuration
HTTPEnabled bool `yaml:"http_enabled"` // Enable HTTP server mode
HTTPListenAddr string `yaml:"http_listen_addr"` // Address to listen on (e.g., ":8443")
HTTPTLSCertFile string `yaml:"http_tls_cert"` // Path to TLS certificate
HTTPTLSKeyFile string `yaml:"http_tls_key"` // Path to TLS private key
HTTPAuthToken string `yaml:"http_auth_token"` // Bearer token for authentication
}
// PeerConfig represents a peer entry with capabilities.
type PeerConfig struct {
UID uint32 `yaml:"uid"`
Capabilities []string `yaml:"capabilities"`
}
// loadConfig loads configuration from file and environment variables
func loadConfig(configPath string) (*Config, error) {
cfg := &Config{
AllowIDMappedRoot: true,
AllowedIDMapUsers: []string{"root"},
LogLevel: "info", // Default log level
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
MaxSSHOutputBytes: 1 * 1024 * 1024, // 1 MiB
}
// Try to load config file if it exists
if configPath != "" {
if _, err := os.Stat(configPath); err == nil {
data, err := os.ReadFile(configPath)
if err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("failed to parse config file: %w", err)
}
log.Info().
Str("config_file", configPath).
Int("subnet_count", len(cfg.AllowedSourceSubnets)).
Msg("Loaded configuration from file")
}
}
// Read timeout override
if envReadTimeout := os.Getenv("PULSE_SENSOR_PROXY_READ_TIMEOUT"); envReadTimeout != "" {
if parsed, err := time.ParseDuration(strings.TrimSpace(envReadTimeout)); err != nil {
log.Warn().Str("value", envReadTimeout).Err(err).Msg("Invalid PULSE_SENSOR_PROXY_READ_TIMEOUT value, ignoring")
} else {
cfg.ReadTimeout = parsed
log.Info().Dur("read_timeout", cfg.ReadTimeout).Msg("Configured read timeout from environment")
}
}
// Write timeout override
if envWriteTimeout := os.Getenv("PULSE_SENSOR_PROXY_WRITE_TIMEOUT"); envWriteTimeout != "" {
if parsed, err := time.ParseDuration(strings.TrimSpace(envWriteTimeout)); err != nil {
log.Warn().Str("value", envWriteTimeout).Err(err).Msg("Invalid PULSE_SENSOR_PROXY_WRITE_TIMEOUT value, ignoring")
} else {
cfg.WriteTimeout = parsed
log.Info().Dur("write_timeout", cfg.WriteTimeout).Msg("Configured write timeout from environment")
}
}
// Append from environment variable if set
if envSubnets := os.Getenv("PULSE_SENSOR_PROXY_ALLOWED_SUBNETS"); envSubnets != "" {
envList := strings.Split(envSubnets, ",")
cfg.AllowedSourceSubnets = append(cfg.AllowedSourceSubnets, envList...)
log.Info().
Int("env_subnet_count", len(envList)).
Msg("Appended subnets from environment variable")
}
// Ensure timeouts have sane defaults
if cfg.ReadTimeout <= 0 {
log.Warn().Dur("configured_value", cfg.ReadTimeout).Msg("Read timeout must be positive; using default 5s")
cfg.ReadTimeout = 5 * time.Second
}
if cfg.WriteTimeout <= 0 {
log.Warn().Dur("configured_value", cfg.WriteTimeout).Msg("Write timeout must be positive; using default 10s")
cfg.WriteTimeout = 10 * time.Second
}
if cfg.MaxSSHOutputBytes <= 0 {
log.Warn().Int64("configured_value", cfg.MaxSSHOutputBytes).Msg("max_ssh_output_bytes must be positive; using default 1MiB")
cfg.MaxSSHOutputBytes = 1 * 1024 * 1024
}
// Allow ID-mapped root override
if envAllowIDMap := os.Getenv("PULSE_SENSOR_PROXY_ALLOW_IDMAPPED_ROOT"); envAllowIDMap != "" {
parsed, err := parseBool(envAllowIDMap)
if err != nil {
log.Warn().
Str("value", envAllowIDMap).
Err(err).
Msg("Invalid PULSE_SENSOR_PROXY_ALLOW_IDMAPPED_ROOT value, ignoring")
} else {
cfg.AllowIDMappedRoot = parsed
log.Info().
Bool("allow_idmapped_root", parsed).
Msg("Configured allow_idmapped_root from environment variable")
}
}
// Allowed ID map users override
if envIDMapUsers := os.Getenv("PULSE_SENSOR_PROXY_ALLOWED_IDMAP_USERS"); envIDMapUsers != "" {
envList := splitAndTrim(envIDMapUsers)
if len(envList) > 0 {
cfg.AllowedIDMapUsers = envList
log.Info().
Strs("allowed_idmap_users", cfg.AllowedIDMapUsers).
Msg("Configured allowed ID map users from environment")
}
}
// Allowed peer UID overrides
if envUIDs := os.Getenv("PULSE_SENSOR_PROXY_ALLOWED_PEER_UIDS"); envUIDs != "" {
parsed, err := parseUint32List(envUIDs)
if err != nil {
log.Warn().
Str("value", envUIDs).
Err(err).
Msg("Invalid PULSE_SENSOR_PROXY_ALLOWED_PEER_UIDS value, ignoring")
} else {
cfg.AllowedPeerUIDs = append(cfg.AllowedPeerUIDs, parsed...)
log.Info().
Int("env_uid_count", len(parsed)).
Msg("Appended allowed peer UIDs from environment")
}
}
// Allowed peer GID overrides
if envGIDs := os.Getenv("PULSE_SENSOR_PROXY_ALLOWED_PEER_GIDS"); envGIDs != "" {
parsed, err := parseUint32List(envGIDs)
if err != nil {
log.Warn().
Str("value", envGIDs).
Err(err).
Msg("Invalid PULSE_SENSOR_PROXY_ALLOWED_PEER_GIDS value, ignoring")
} else {
cfg.AllowedPeerGIDs = append(cfg.AllowedPeerGIDs, parsed...)
log.Info().
Int("env_gid_count", len(parsed)).
Msg("Appended allowed peer GIDs from environment")
}
}
// Allowed node overrides
if envNodes := os.Getenv("PULSE_SENSOR_PROXY_ALLOWED_NODES"); envNodes != "" {
envList := splitAndTrim(envNodes)
if len(envList) > 0 {
cfg.AllowedNodes = append(cfg.AllowedNodes, envList...)
log.Info().
Int("env_allowed_nodes", len(envList)).
Msg("Appended allowed nodes from environment")
}
}
// Strict node validation override
if envStrict := os.Getenv("PULSE_SENSOR_PROXY_STRICT_NODE_VALIDATION"); envStrict != "" {
parsed, err := parseBool(envStrict)
if err != nil {
log.Warn().
Str("value", envStrict).
Err(err).
Msg("Invalid PULSE_SENSOR_PROXY_STRICT_NODE_VALIDATION value, ignoring")
} else {
cfg.StrictNodeValidation = parsed
log.Info().
Bool("strict_node_validation", parsed).
Msg("Configured strict node validation from environment")
}
}
// SSH output limit override
if envMaxSSH := os.Getenv("PULSE_SENSOR_PROXY_MAX_SSH_OUTPUT_BYTES"); envMaxSSH != "" {
if parsed, err := strconv.ParseInt(strings.TrimSpace(envMaxSSH), 10, 64); err != nil {
log.Warn().Str("value", envMaxSSH).Err(err).Msg("Invalid PULSE_SENSOR_PROXY_MAX_SSH_OUTPUT_BYTES value, ignoring")
} else {
cfg.MaxSSHOutputBytes = parsed
log.Info().Int64("max_ssh_output_bytes", cfg.MaxSSHOutputBytes).Msg("Configured max SSH output bytes from environment")
}
}
// Require Proxmox host keys override
if envReq := os.Getenv("PULSE_SENSOR_PROXY_REQUIRE_PROXMOX_HOSTKEYS"); envReq != "" {
if parsed, err := parseBool(envReq); err != nil {
log.Warn().Str("value", envReq).Err(err).Msg("Invalid PULSE_SENSOR_PROXY_REQUIRE_PROXMOX_HOSTKEYS value, ignoring")
} else {
cfg.RequireProxmoxHostkeys = parsed
log.Info().Bool("require_proxmox_hostkeys", parsed).Msg("Configured Proxmox host key requirement from environment")
}
}
// Metrics address from environment variable
if envMetrics := os.Getenv("PULSE_SENSOR_PROXY_METRICS_ADDR"); envMetrics != "" {
cfg.MetricsAddress = envMetrics
log.Info().Str("metrics_addr", envMetrics).Msg("Metrics address set from environment")
}
// Default metrics address if not configured
if cfg.MetricsAddress == "" {
cfg.MetricsAddress = "default" // Will use defaultMetricsAddr
}
// Parse and validate all subnets
if len(cfg.AllowedSourceSubnets) > 0 {
normalized, err := parseAllowedSubnets(cfg.AllowedSourceSubnets)
if err != nil {
return nil, fmt.Errorf("invalid subnet configuration: %w", err)
}
cfg.AllowedSourceSubnets = normalized
log.Info().
Strs("allowed_subnets", cfg.AllowedSourceSubnets).
Msg("Validated and normalized subnet configuration")
} else {
// Auto-detect if no configuration provided
detected := detectHostCIDRs()
if len(detected) == 0 {
log.Warn().Msg("No allowed_source_subnets configured and no host addresses detected")
} else {
cfg.AllowedSourceSubnets = detected
log.Warn().
Strs("auto_detected_subnets", detected).
Msg("No allowed_source_subnets configured; using detected host addresses (recommended to configure explicitly)")
}
}
// Log rate limit configuration if provided
if cfg.RateLimit != nil {
log.Info().
Int("per_peer_interval_ms", cfg.RateLimit.PerPeerIntervalMs).
Int("per_peer_burst", cfg.RateLimit.PerPeerBurst).
Msg("Rate limit configuration loaded from config file")
}
// Log level from environment variable
if envLogLevel := os.Getenv("PULSE_SENSOR_PROXY_LOG_LEVEL"); envLogLevel != "" {
cfg.LogLevel = strings.ToLower(strings.TrimSpace(envLogLevel))
log.Info().Str("log_level", cfg.LogLevel).Msg("Log level set from environment")
}
// HTTP mode configuration from environment variables
if envHTTPEnabled := os.Getenv("PULSE_SENSOR_PROXY_HTTP_ENABLED"); envHTTPEnabled != "" {
if parsed, err := parseBool(envHTTPEnabled); err != nil {
log.Warn().Str("value", envHTTPEnabled).Err(err).Msg("Invalid PULSE_SENSOR_PROXY_HTTP_ENABLED value, ignoring")
} else {
cfg.HTTPEnabled = parsed
log.Info().Bool("http_enabled", parsed).Msg("HTTP mode enabled from environment")
}
}
if envHTTPAddr := os.Getenv("PULSE_SENSOR_PROXY_HTTP_ADDR"); envHTTPAddr != "" {
cfg.HTTPListenAddr = strings.TrimSpace(envHTTPAddr)
log.Info().Str("http_addr", cfg.HTTPListenAddr).Msg("HTTP listen address set from environment")
}
if envHTTPCert := os.Getenv("PULSE_SENSOR_PROXY_HTTP_TLS_CERT"); envHTTPCert != "" {
cfg.HTTPTLSCertFile = strings.TrimSpace(envHTTPCert)
log.Info().Str("tls_cert", cfg.HTTPTLSCertFile).Msg("HTTP TLS cert path set from environment")
}
if envHTTPKey := os.Getenv("PULSE_SENSOR_PROXY_HTTP_TLS_KEY"); envHTTPKey != "" {
cfg.HTTPTLSKeyFile = strings.TrimSpace(envHTTPKey)
log.Info().Str("tls_key", cfg.HTTPTLSKeyFile).Msg("HTTP TLS key path set from environment")
}
if envHTTPToken := os.Getenv("PULSE_SENSOR_PROXY_HTTP_AUTH_TOKEN"); envHTTPToken != "" {
cfg.HTTPAuthToken = strings.TrimSpace(envHTTPToken)
log.Info().Msg("HTTP auth token set from environment")
}
// Validate HTTP configuration if enabled
if cfg.HTTPEnabled {
if cfg.HTTPListenAddr == "" {
cfg.HTTPListenAddr = ":8443" // Default port
log.Info().Str("http_addr", cfg.HTTPListenAddr).Msg("Using default HTTP listen address")
}
if cfg.HTTPAuthToken == "" {
return nil, fmt.Errorf("http_enabled=true requires http_auth_token to be configured")
}
if cfg.HTTPTLSCertFile == "" || cfg.HTTPTLSKeyFile == "" {
return nil, fmt.Errorf("http_enabled=true requires both http_tls_cert and http_tls_key")
}
}
return cfg, nil
}
// parseBool returns boolean value for various truthy/falsy strings
func parseBool(raw string) (bool, error) {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "1", "true", "yes", "on":
return true, nil
case "0", "false", "no", "off":
return false, nil
default:
return false, fmt.Errorf("invalid boolean value: %s", raw)
}
}
// parseUint32List parses comma-separated uint32 list
func parseUint32List(raw string) ([]uint32, error) {
var parsed []uint32
parts := splitAndTrim(raw)
for _, part := range parts {
if part == "" {
continue
}
val, err := strconv.ParseUint(part, 10, 32)
if err != nil {
return nil, fmt.Errorf("invalid uint32 %q: %w", part, err)
}
parsed = append(parsed, uint32(val))
}
return parsed, nil
}
// splitAndTrim splits a comma-separated string and trims whitespace
func splitAndTrim(raw string) []string {
parts := strings.Split(raw, ",")
var result []string
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
result = append(result, trimmed)
}
}
return result
}
// detectHostCIDRs detects local host IP addresses as /32 (IPv4) or /128 (IPv6) CIDRs
func detectHostCIDRs() []string {
var cidrs []string
ifaces, err := net.Interfaces()
if err != nil {
log.Warn().Err(err).Msg("Failed to enumerate network interfaces")
return cidrs
}
for _, iface := range ifaces {
// Skip down or loopback interfaces
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
log.Warn().Str("iface", iface.Name).Err(err).Msg("Address lookup failed")
continue
}
for _, addr := range addrs {
ipNet, ok := addr.(*net.IPNet)
if !ok {
continue
}
ip := ipNet.IP
// Skip loopback and link-local addresses
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
continue
}
// Add as /32 for IPv4, /128 for IPv6
if ip.To4() != nil {
cidrs = append(cidrs, ip.String()+"/32")
} else if ip.To16() != nil {
cidrs = append(cidrs, ip.String()+"/128")
}
}
}
return cidrs
}
// parseAllowedSubnets validates and normalizes subnet specifications
func parseAllowedSubnets(cfg []string) ([]string, error) {
seen := make(map[string]struct{})
var normalized []string
for _, raw := range cfg {
entry := strings.TrimSpace(raw)
if entry == "" {
continue
}
// Try parsing as CIDR
if _, _, err := net.ParseCIDR(entry); err == nil {
if _, exists := seen[entry]; !exists {
seen[entry] = struct{}{}
normalized = append(normalized, entry)
}
continue
}
// Try parsing as single IP
if ip := net.ParseIP(entry); ip != nil {
norm := entry + "/32"
if ip.To4() == nil {
norm = entry + "/128"
}
if _, exists := seen[norm]; !exists {
seen[norm] = struct{}{}
normalized = append(normalized, norm)
}
continue
}
// Invalid format
return nil, fmt.Errorf("invalid subnet or address: %s", entry)
}
return normalized, nil
}