Implements bullet-proof configuration management to completely eliminate
allowed_nodes corruption by design. This builds on Phase 1 (file-only mode)
by replacing all shell/Python config manipulation with proper Go tooling.
**New Features:**
- `pulse-sensor-proxy config validate` - parse and validate config files
- `pulse-sensor-proxy config set-allowed-nodes` - atomic node list updates
- File locking via flock prevents concurrent write races
- Atomic writes (temp file + rename) ensure consistency
- systemd ExecStartPre validation prevents startup with bad config
**Architectural Changes:**
1. Installer now calls config CLI instead of embedded Python/shell scripts
2. All config mutations go through single authoritative writer
3. Deduplication and normalization handled in Go (reuses existing logic)
4. Sanitizer kept as noisy failsafe (warns if corruption still occurs)
**Implementation Details:**
- New cmd/pulse-sensor-proxy/config_cmd.go with cobra commands
- withLockedFile() wrapper ensures exclusive access
- atomicWriteFile() uses temp + rename pattern
- Installer update_allowed_nodes() simplified to CLI calls
- Both systemd service modes include ExecStartPre validation
**Why This Works:**
- Single code path for all writes (no shell/Python divergence)
- File locking serializes self-heal timer + manual installer runs
- Validation gate prevents proxy from starting with corrupt config
- CLI uses same YAML parser as the daemon (guaranteed compatibility)
**Phase 2 Benefits:**
- Corruption impossible by design (not just detected and fixed)
- No more Python dependency for config management
- Atomic operations prevent partial writes
- Clear error messages on validation failures
The defensive sanitizer remains active but now logs loudly if triggered,
allowing us to confirm Phase 2 eliminates corruption in production before
removing the safety net entirely.
This completes the fix for the recurring temperature monitoring outages.
Related to Phase 1 commit 53dec6010
614 lines
19 KiB
Go
614 lines
19 KiB
Go
package main
|
||
|
||
import (
|
||
"bufio"
|
||
"bytes"
|
||
"fmt"
|
||
"net"
|
||
"os"
|
||
"regexp"
|
||
"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"`
|
||
AllowedNodesFile string `yaml:"allowed_nodes_file"`
|
||
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
|
||
|
||
PulseControlPlane *ControlPlaneConfig `yaml:"pulse_control_plane"`
|
||
}
|
||
|
||
type ControlPlaneConfig struct {
|
||
URL string `yaml:"url"`
|
||
TokenFile string `yaml:"token_file"`
|
||
RefreshIntervalSec int `yaml:"refresh_interval"` // seconds
|
||
InsecureSkipVerify bool `yaml:"insecure_skip_verify"`
|
||
}
|
||
|
||
const defaultAllowedNodesFile = "/etc/pulse-sensor-proxy/allowed_nodes.yaml"
|
||
|
||
// 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 sanitized, newData := sanitizeDuplicateAllowedNodesBlocks(configPath, data); sanitized {
|
||
data = newData
|
||
}
|
||
|
||
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")
|
||
}
|
||
}
|
||
|
||
if cfg.AllowedNodesFile == "" {
|
||
if _, err := os.Stat(defaultAllowedNodesFile); err == nil {
|
||
cfg.AllowedNodesFile = defaultAllowedNodesFile
|
||
}
|
||
}
|
||
|
||
if cfg.AllowedNodesFile != "" {
|
||
if fileNodes, err := loadAllowedNodesFile(cfg.AllowedNodesFile); err != nil {
|
||
log.Warn().
|
||
Err(err).
|
||
Str("allowed_nodes_file", cfg.AllowedNodesFile).
|
||
Msg("Failed to load allowed nodes file")
|
||
} else if len(fileNodes) > 0 {
|
||
cfg.AllowedNodes = append(cfg.AllowedNodes, fileNodes...)
|
||
log.Info().
|
||
Str("allowed_nodes_file", cfg.AllowedNodesFile).
|
||
Int("allowed_node_count", len(fileNodes)).
|
||
Msg("Loaded allowed nodes from file")
|
||
}
|
||
}
|
||
|
||
cfg.AllowedNodes = normalizeNodes(cfg.AllowedNodes)
|
||
|
||
// 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")
|
||
}
|
||
}
|
||
|
||
if cfg.PulseControlPlane != nil {
|
||
if cfg.PulseControlPlane.TokenFile == "" {
|
||
cfg.PulseControlPlane.TokenFile = defaultControlPlaneTokenPath
|
||
}
|
||
if cfg.PulseControlPlane.RefreshIntervalSec <= 0 {
|
||
cfg.PulseControlPlane.RefreshIntervalSec = defaultControlPlaneRefreshSecs
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
func normalizeNodes(nodes []string) []string {
|
||
seen := make(map[string]struct{})
|
||
var result []string
|
||
for _, node := range nodes {
|
||
trimmed := strings.TrimSpace(node)
|
||
if trimmed == "" {
|
||
continue
|
||
}
|
||
key := strings.ToLower(trimmed)
|
||
if _, exists := seen[key]; exists {
|
||
continue
|
||
}
|
||
seen[key] = struct{}{}
|
||
result = append(result, trimmed)
|
||
}
|
||
return result
|
||
}
|
||
|
||
func loadAllowedNodesFile(path string) ([]string, error) {
|
||
data, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
type wrapper struct {
|
||
AllowedNodes []string `yaml:"allowed_nodes"`
|
||
}
|
||
|
||
var w wrapper
|
||
if err := yaml.Unmarshal(data, &w); err == nil && len(w.AllowedNodes) > 0 {
|
||
return normalizeNodes(w.AllowedNodes), nil
|
||
}
|
||
|
||
var list []string
|
||
if err := yaml.Unmarshal(data, &list); err == nil && len(list) > 0 {
|
||
return normalizeNodes(list), nil
|
||
}
|
||
|
||
scanner := bufio.NewScanner(bytes.NewReader(data))
|
||
var plain []string
|
||
for scanner.Scan() {
|
||
line := strings.TrimSpace(scanner.Text())
|
||
if line == "" || strings.HasPrefix(line, "#") {
|
||
continue
|
||
}
|
||
if strings.HasPrefix(line, "-") {
|
||
line = strings.TrimSpace(strings.TrimPrefix(line, "-"))
|
||
}
|
||
if line != "" {
|
||
plain = append(plain, line)
|
||
}
|
||
}
|
||
if err := scanner.Err(); err != nil {
|
||
return normalizeNodes(plain), err
|
||
}
|
||
return normalizeNodes(plain), nil
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
var allowedNodesBlockPattern = regexp.MustCompile(`(?m)(?:^[ \t]*#.*\n)*^[ \t]*allowed_nodes:\n(?:^[ \t]+-.*\n?)+`)
|
||
|
||
func sanitizeDuplicateAllowedNodesBlocks(path string, data []byte) (bool, []byte) {
|
||
matches := allowedNodesBlockPattern.FindAllIndex(data, -1)
|
||
if len(matches) <= 1 {
|
||
return false, data
|
||
}
|
||
|
||
var buf bytes.Buffer
|
||
last := 0
|
||
for idx, match := range matches {
|
||
start, end := match[0], match[1]
|
||
if idx == 0 {
|
||
buf.Write(data[last:end])
|
||
} else {
|
||
buf.Write(data[last:start])
|
||
}
|
||
last = end
|
||
}
|
||
buf.Write(data[last:])
|
||
|
||
cleaned := buf.Bytes()
|
||
if path != "" {
|
||
if err := os.WriteFile(path, cleaned, 0600); err != nil {
|
||
log.Warn().
|
||
Err(err).
|
||
Str("config_file", path).
|
||
Msg("Failed to rewrite sanitized configuration; using in-memory copy only")
|
||
} else {
|
||
// Phase 2: This sanitizer should never fire with the new config CLI
|
||
// If it does, it indicates a bug or manual file editing
|
||
log.Warn().
|
||
Str("config_file", path).
|
||
Int("removed_duplicate_blocks", len(matches)-1).
|
||
Msg("CONFIG SANITIZED – duplicate allowed_nodes blocks removed; this should not happen with Phase 2 config CLI – please report if you see this")
|
||
}
|
||
} else {
|
||
log.Warn().
|
||
Int("removed_duplicate_blocks", len(matches)-1).
|
||
Msg("CONFIG SANITIZED – duplicate allowed_nodes blocks detected in-memory – this should not happen with Phase 2 config CLI")
|
||
}
|
||
|
||
return true, cleaned
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
const (
|
||
defaultControlPlaneTokenPath = "/etc/pulse-sensor-proxy/.pulse-control-token"
|
||
defaultControlPlaneRefreshSecs = 60
|
||
)
|