diff --git a/docs/TEMPERATURE_MONITORING_SECURITY.md b/docs/TEMPERATURE_MONITORING_SECURITY.md index ea2dec47f..dec150299 100644 --- a/docs/TEMPERATURE_MONITORING_SECURITY.md +++ b/docs/TEMPERATURE_MONITORING_SECURITY.md @@ -193,15 +193,17 @@ from="192.168.0.0/24,10.0.0.0/8" **In containers**, direct SSH is blocked: ```go -if isRunningInContainer() && !devModeAllowSSH { +if system.InContainer() && !devModeAllowSSH { log.Error().Msg("SECURITY BLOCK: SSH temperature collection disabled in containers") return &Temperature{Available: false}, nil } ``` **Container Detection Methods**: -1. Check for `/.dockerenv` file -2. Check `/proc/1/cgroup` for "docker", "lxc", "containerd" +1. `PULSE_FORCE_CONTAINER=1` override for explicit opt-in +2. Presence of `/.dockerenv` or `/run/.containerenv` +3. `container=` hints from environment variables +4. `/proc/1/environ` and `/proc/1/cgroup` markers (`docker`, `lxc`, `containerd`, `kubepods`, etc.) **Bypass**: Only possible with explicit environment variable (see [Development Mode](#development-mode)) diff --git a/internal/api/config_handlers.go b/internal/api/config_handlers.go index 769914924..10c8a886e 100644 --- a/internal/api/config_handlers.go +++ b/internal/api/config_handlers.go @@ -28,6 +28,7 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/mock" "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" + "github.com/rcourtman/pulse-go-rewrite/internal/system" "github.com/rcourtman/pulse-go-rewrite/internal/tempproxy" "github.com/rcourtman/pulse-go-rewrite/internal/websocket" pkgdiscovery "github.com/rcourtman/pulse-go-rewrite/pkg/discovery" @@ -2664,25 +2665,6 @@ func (h *ConfigHandlers) HandleVerifyTemperatureSSH(w http.ResponseWriter, r *ht w.Write([]byte(response.String())) } -// isRunningInContainer detects if Pulse is running inside a container -func isRunningInContainer() bool { - // Check for /.dockerenv file - if _, err := os.Stat("/.dockerenv"); err == nil { - return true - } - - // Check cgroup for container indicators - data, err := os.ReadFile("/proc/1/cgroup") - if err == nil { - content := string(data) - if strings.Contains(content, "docker") || strings.Contains(content, "lxc") || strings.Contains(content, "containerd") { - return true - } - } - - return false -} - // generateNodeID creates a unique ID for a node func generateNodeID(nodeType string, index int) string { return fmt.Sprintf("%s-%d", nodeType, index) @@ -5579,7 +5561,7 @@ func (h *ConfigHandlers) getOrGenerateSSHKeys() SSHKeyPair { // CRITICAL SECURITY CHECK: Never generate SSH keys in containers (unless dev mode) // Container compromise = SSH key compromise = root access to Proxmox devModeAllowSSH := os.Getenv("PULSE_DEV_ALLOW_CONTAINER_SSH") == "true" - if isRunningInContainer() && !devModeAllowSSH { + if system.InContainer() && !devModeAllowSSH { log.Error().Msg("SECURITY BLOCK: SSH key generation disabled in containerized deployments") log.Error().Msg("For temperature monitoring in containers, deploy pulse-sensor-proxy on the Proxmox host") log.Error().Msg("See: https://docs.pulseapp.io/security/containerized-deployments") @@ -5587,7 +5569,7 @@ func (h *ConfigHandlers) getOrGenerateSSHKeys() SSHKeyPair { return SSHKeyPair{} } - if devModeAllowSSH && isRunningInContainer() { + if devModeAllowSSH && system.InContainer() { log.Warn().Msg("⚠️ DEV MODE: SSH key generation ENABLED in container - FOR TESTING ONLY") log.Warn().Msg("⚠️ This grants root SSH access from container - NEVER use in production!") } diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 85c1be237..bb33952dd 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -29,6 +29,7 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/mock" "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rcourtman/pulse-go-rewrite/internal/notifications" + "github.com/rcourtman/pulse-go-rewrite/internal/system" "github.com/rcourtman/pulse-go-rewrite/internal/types" "github.com/rcourtman/pulse-go-rewrite/internal/websocket" agentsdocker "github.com/rcourtman/pulse-go-rewrite/pkg/agents/docker" @@ -2978,7 +2979,7 @@ func (m *Monitor) GetConnectionStatuses() map[string]bool { // in a container with SSH-based temperature monitoring enabled func checkContainerizedTempMonitoring() { // Check if running in container - isContainer := os.Getenv("PULSE_DOCKER") == "true" || isRunningInContainer() + isContainer := os.Getenv("PULSE_DOCKER") == "true" || system.InContainer() if !isContainer { return } @@ -3002,27 +3003,6 @@ func checkContainerizedTempMonitoring() { "See documentation for hardening recommendations.") } -// isRunningInContainer detects if running inside a container -func isRunningInContainer() bool { - // Check for /.dockerenv - if _, err := os.Stat("/.dockerenv"); err == nil { - return true - } - - // Check cgroup for container indicators - data, err := os.ReadFile("/proc/1/cgroup") - if err == nil { - content := string(data) - if strings.Contains(content, "docker") || - strings.Contains(content, "lxc") || - strings.Contains(content, "containerd") { - return true - } - } - - return false -} - // New creates a new Monitor instance func New(cfg *config.Config) (*Monitor, error) { // Initialize temperature collector with sensors SSH key diff --git a/internal/monitoring/temperature.go b/internal/monitoring/temperature.go index f6e1dd994..544b505fd 100644 --- a/internal/monitoring/temperature.go +++ b/internal/monitoring/temperature.go @@ -17,6 +17,7 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rcourtman/pulse-go-rewrite/internal/ssh/knownhosts" + "github.com/rcourtman/pulse-go-rewrite/internal/system" "github.com/rcourtman/pulse-go-rewrite/internal/tempproxy" "github.com/rs/zerolog/log" ) @@ -102,7 +103,7 @@ func (tc *TemperatureCollector) CollectTemperature(ctx context.Context, nodeHost // SECURITY: Block SSH fallback when running in containers (unless dev mode) // Container compromise = SSH key compromise = root access to infrastructure devModeAllowSSH := os.Getenv("PULSE_DEV_ALLOW_CONTAINER_SSH") == "true" - if isRunningInContainer() && !devModeAllowSSH { + if system.InContainer() && !devModeAllowSSH { log.Error(). Str("node", nodeName). Msg("SECURITY BLOCK: SSH temperature collection disabled in containers - deploy pulse-sensor-proxy") @@ -600,8 +601,8 @@ func (tc *TemperatureCollector) shouldDisableProxy(err error) bool { var proxyErr *tempproxy.ProxyError if errors.As(err, &proxyErr) { switch proxyErr.Type { - case tempproxy.ErrorTypeTransport, tempproxy.ErrorTypeTimeout: - return true + case tempproxy.ErrorTypeTransport, tempproxy.ErrorTypeTimeout: + return true default: return false } diff --git a/internal/system/container.go b/internal/system/container.go new file mode 100644 index 000000000..23ff3f10b --- /dev/null +++ b/internal/system/container.go @@ -0,0 +1,66 @@ +package system + +import ( + "os" + "strings" +) + +var containerMarkers = []string{ + "docker", + "lxc", + "containerd", + "kubepods", + "podman", + "crio", + "libpod", + "lxcfs", +} + +// InContainer reports whether Pulse is running inside a containerised environment. +func InContainer() bool { + // Allow operators to force container behaviour when automatic detection falls short. + if isTruthy(os.Getenv("PULSE_FORCE_CONTAINER")) { + return true + } + + if _, err := os.Stat("/.dockerenv"); err == nil { + return true + } + if _, err := os.Stat("/run/.containerenv"); err == nil { + return true + } + + // Check common environment hints provided by systemd/nspawn, LXC, etc. + if val := strings.ToLower(strings.TrimSpace(os.Getenv("container"))); val != "" && val != "host" { + return true + } + + // Some distros expose the container hint through PID 1's environment. + if data, err := os.ReadFile("/proc/1/environ"); err == nil { + lower := strings.ToLower(string(data)) + if strings.Contains(lower, "container=") && !strings.Contains(lower, "container=host") { + return true + } + } + + // Fall back to cgroup inspection which covers older Docker/LXC setups. + if data, err := os.ReadFile("/proc/1/cgroup"); err == nil { + content := strings.ToLower(string(data)) + for _, marker := range containerMarkers { + if strings.Contains(content, marker) { + return true + } + } + } + + return false +} + +func isTruthy(value string) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "1", "true", "t", "yes", "y", "on": + return true + default: + return false + } +}