diff --git a/cmd/pulse-agent/main.go b/cmd/pulse-agent/main.go index 209c9fac16..bee626acb5 100644 --- a/cmd/pulse-agent/main.go +++ b/cmd/pulse-agent/main.go @@ -590,38 +590,39 @@ func run(ctx context.Context, args []string, getenv func(string) string) error { } } hostCfg := hostagent.Config{ - PulseURL: cfg.PulseURL, - APIToken: cfg.APIToken, - Interval: cfg.Interval, - HostnameOverride: cfg.HostnameOverride, - AgentID: cfg.AgentID, - AgentType: "unified", - AgentVersion: Version, - Tags: cfg.Tags, - InsecureSkipVerify: cfg.InsecureSkipVerify, - CACertPath: cfg.CACertPath, - ServerFingerprint: cfg.ServerFingerprint, - CustomSensorsFile: cfg.CustomSensorsFile, - DeploySSHUser: cfg.DeploySSHUser, - LogLevel: cfg.LogLevel, - Logger: &logger, - EnableProxmox: cfg.EnableProxmox, - ProxmoxType: cfg.ProxmoxType, - EnableCommands: cfg.EnableCommands, - CommandAuthorityProfile: cfg.CommandAuthorityProfile, - Enroll: cfg.Enroll, - DiskExclude: cfg.DiskExclude, - DiskInclude: cfg.DiskInclude, - StateDir: cfg.StateDir, - ReportIP: cfg.ReportIP, - DisableCeph: cfg.DisableCeph, - AvailabilityTargets: cfg.AvailabilityTargets, - AppliedConfig: cfg.AppliedConfig, - ModuleStatus: runtimeStatus.moduleStatuses, - PrivilegeHelperStatus: privilegeHelperStatus, - Observers: hostObserverTargets(cfg.Observers), - PrivilegedTelemetry: privilegedTelemetry, - UpdatedFromVersion: pendingUpdatePreviousVersion(pendingUpdate), + PulseURL: cfg.PulseURL, + APIToken: cfg.APIToken, + Interval: cfg.Interval, + HostnameOverride: cfg.HostnameOverride, + AgentID: cfg.AgentID, + AgentType: "unified", + AgentVersion: Version, + Tags: cfg.Tags, + InsecureSkipVerify: cfg.InsecureSkipVerify, + CACertPath: cfg.CACertPath, + ServerFingerprint: cfg.ServerFingerprint, + CustomSensorsFile: cfg.CustomSensorsFile, + DeploySSHUser: cfg.DeploySSHUser, + LogLevel: cfg.LogLevel, + Logger: &logger, + EnableProxmox: cfg.EnableProxmox, + ProxmoxType: cfg.ProxmoxType, + EnableCommands: cfg.EnableCommands, + CommandAuthorityProfile: cfg.CommandAuthorityProfile, + Enroll: cfg.Enroll, + DiskExclude: cfg.DiskExclude, + DiskInclude: cfg.DiskInclude, + StateDir: cfg.StateDir, + ReportIP: cfg.ReportIP, + DisableCeph: cfg.DisableCeph, + DisableClusterPeerSensors: cfg.DisableClusterPeerSensors, + AvailabilityTargets: cfg.AvailabilityTargets, + AppliedConfig: cfg.AppliedConfig, + ModuleStatus: runtimeStatus.moduleStatuses, + PrivilegeHelperStatus: privilegeHelperStatus, + Observers: hostObserverTargets(cfg.Observers), + PrivilegedTelemetry: privilegedTelemetry, + UpdatedFromVersion: pendingUpdatePreviousVersion(pendingUpdate), DockerContainerUpdater: dockerUpdaterBridge, DockerContainerLifecycleOperator: dockerUpdaterBridge, @@ -1106,9 +1107,10 @@ type Config struct { DiskInclude []string // Devices or mount points to opt into monitoring despite automatic filtering // Network configuration - ReportIP string // IP address to report (for multi-NIC systems) - DisableCeph bool // Disable local Ceph status polling - SelfTest bool // Perform self-test and exit + ReportIP string // IP address to report (for multi-NIC systems) + DisableCeph bool // Disable local Ceph status polling + DisableClusterPeerSensors bool // Keep Proxmox monitoring local and skip SSH sensor collection from cluster peers + SelfTest bool // Perform self-test and exit // AvailabilityTargets are externally probed availability checks assigned to // this agent by the server. Remote config is the only source. @@ -1242,6 +1244,7 @@ func loadConfig(args []string, getenv func(string) string) (Config, error) { envDiskInclude := strings.TrimSpace(getenv("PULSE_DISK_INCLUDE")) envReportIP := strings.TrimSpace(getenv("PULSE_REPORT_IP")) envDisableCeph := strings.TrimSpace(getenv("PULSE_DISABLE_CEPH")) + envDisableClusterPeerSensors := strings.TrimSpace(getenv("PULSE_DISABLE_CLUSTER_PEER_SENSORS")) envObserversFile := strings.TrimSpace(getenv("PULSE_OBSERVERS_FILE")) envCustomSensorsFile := strings.TrimSpace(getenv("PULSE_CUSTOM_SENSORS_FILE")) @@ -1325,6 +1328,7 @@ func loadConfig(args []string, getenv func(string) string) (Config, error) { stateDirFlag := fs.String("state-dir", envStateDir, "Persistent state directory (default: platform service state directory)") reportIPFlag := fs.String("report-ip", envReportIP, "IP address to report (for multi-NIC systems)") disableCephFlag := fs.Bool("disable-ceph", utils.ParseBool(envDisableCeph), "Disable local Ceph status polling") + disableClusterPeerSensorsFlag := fs.Bool("disable-cluster-peer-sensors", utils.ParseBool(envDisableClusterPeerSensors), "Skip SSH sensor collection from Proxmox cluster peers") showVersion := fs.Bool("version", false, "Print the agent version and exit") selfTest := fs.Bool("self-test", false, "Perform self-test and exit (used during auto-update)") @@ -1496,6 +1500,7 @@ func loadConfig(args []string, getenv func(string) string) (Config, error) { DiskInclude: diskInclude, ReportIP: strings.TrimSpace(*reportIPFlag), DisableCeph: *disableCephFlag, + DisableClusterPeerSensors: *disableClusterPeerSensorsFlag, SelfTest: *selfTest, }, nil } diff --git a/cmd/pulse-agent/main_test.go b/cmd/pulse-agent/main_test.go index f3889eaa78..1466717215 100644 --- a/cmd/pulse-agent/main_test.go +++ b/cmd/pulse-agent/main_test.go @@ -348,6 +348,16 @@ func TestApplyRemoteSettings(t *testing.T) { } } +func TestLoadConfigDisableClusterPeerSensorsFlag(t *testing.T) { + cfg, err := loadConfig([]string{"--disable-cluster-peer-sensors"}, func(string) string { return "" }) + if err != nil { + t.Fatalf("loadConfig returned unexpected error: %v", err) + } + if !cfg.DisableClusterPeerSensors { + t.Fatal("expected --disable-cluster-peer-sensors to disable peer sensor collection") + } +} + func TestApplyRemoteSettingsHonorsLocalDockerOptOut(t *testing.T) { logger := zerolog.New(io.Discard) cfg := &Config{ diff --git a/docs/UNIFIED_AGENT.md b/docs/UNIFIED_AGENT.md index a7c3b7f13a..7207a29560 100644 --- a/docs/UNIFIED_AGENT.md +++ b/docs/UNIFIED_AGENT.md @@ -19,6 +19,12 @@ verification guidance. > Note: For agent-based temperature monitoring, use `pulse-agent --enable-proxmox` or SSH-based collection. The legacy sensor proxy has been removed. See `docs/TEMPERATURE_MONITORING.md`. +When each Proxmox cluster node runs an agent, add +`--disable-cluster-peer-sensors` (or set +`PULSE_DISABLE_CLUSTER_PEER_SENSORS=true`) to keep temperature collection local +to each node. This preserves local host telemetry and Proxmox integration while +preventing duplicate root-SSH sensor polling between cluster peers. + ## Quick Start Generate an installation command in the UI: diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 50858dec49..9c5e6ab5d4 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -164,6 +164,15 @@ management, and fleet control surfaces. Pulse v6 has one host-installed infrastructure agent binary, `pulse-agent`; host, Docker / Podman, Kubernetes, Proxmox-local, and other node-local telemetry are modules inside that binary, not separate customer-facing agent products. + +In a Proxmox VE cluster, an enabled agent may collect peer temperature data +through its existing SSH sensor path. `--disable-cluster-peer-sensors` and +`PULSE_DISABLE_CLUSTER_PEER_SENSORS=true` are local, default-off operator +controls that stop only that peer discovery and SSH collection path. They do +not disable the local host, SMART, Ceph, or Proxmox reporting modules, alter +agent enrollment or identity, or add remote command authority. The disabled +path is pinned by `TestCollectClusterSensors_Disabled`; configuration parsing +is pinned by `TestLoadConfigDisableClusterPeerSensorsFlag`. On supported Linux systemd hosts, the opt-in safe runtime is a root-owned, unprivileged monitoring collector plus the no-network typed helper, with remediation installed only as the separate root-owned `pulse-agent-runner`. diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index edd92e93d8..490ee0050a 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -202,6 +202,11 @@ configuration. It may override Pulse's automatic filesystem suppression only; it must not override a matching operator exclusion or introduce remote authority to expand the reported filesystem set silently. +The local `--disable-cluster-peer-sensors` control may suppress only the +agent's outbound Proxmox peer sensor SSH probes. It neither accepts a remote +command nor changes SSH credentials, target authority, agent scopes, token +handling, enrollment, or the local host/SMART/Ceph/Proxmox reporting boundary. + Retained Patrol objective briefs and optional context are operator-authored AI content. They are encrypted at rest in the organization-scoped Pulse data directory and loading fails closed if decryption fails. Audit and telemetry diff --git a/frontend-modern/public/docs/UNIFIED_AGENT.md b/frontend-modern/public/docs/UNIFIED_AGENT.md index a7c3b7f13a..7207a29560 100644 --- a/frontend-modern/public/docs/UNIFIED_AGENT.md +++ b/frontend-modern/public/docs/UNIFIED_AGENT.md @@ -19,6 +19,12 @@ verification guidance. > Note: For agent-based temperature monitoring, use `pulse-agent --enable-proxmox` or SSH-based collection. The legacy sensor proxy has been removed. See `docs/TEMPERATURE_MONITORING.md`. +When each Proxmox cluster node runs an agent, add +`--disable-cluster-peer-sensors` (or set +`PULSE_DISABLE_CLUSTER_PEER_SENSORS=true`) to keep temperature collection local +to each node. This preserves local host telemetry and Proxmox integration while +preventing duplicate root-SSH sensor polling between cluster peers. + ## Quick Start Generate an installation command in the UI: diff --git a/internal/hostagent/agent.go b/internal/hostagent/agent.go index 1820eb9328..aa8eae7e53 100644 --- a/internal/hostagent/agent.go +++ b/internal/hostagent/agent.go @@ -80,8 +80,9 @@ type Config struct { DeploySSHUser string // Default: root. Non-root users must support passwordless sudo for install steps. // Network configuration - ReportIP string // IP address to report instead of auto-detected (for multi-NIC systems) - DisableCeph bool // If true, disables local Ceph status polling + ReportIP string // IP address to report instead of auto-detected (for multi-NIC systems) + DisableCeph bool // If true, disables local Ceph status polling + DisableClusterPeerSensors bool // If true, keeps Proxmox monitoring local and skips SSH sensor collection from cluster peers // AvailabilityTargets are the externally probed availability checks the // server assigned to this agent at startup. Later assignments arrive diff --git a/internal/hostagent/agent_new_test.go b/internal/hostagent/agent_new_test.go index 52650d07fe..682ac9957f 100644 --- a/internal/hostagent/agent_new_test.go +++ b/internal/hostagent/agent_new_test.go @@ -60,6 +60,28 @@ func TestNew_AllowsMissingAPITokenWhenEnrollmentDisabled(t *testing.T) { } } +func TestNew_PreservesDisableClusterPeerSensors(t *testing.T) { + mc := &mockCollector{ + hostInfoFn: func(context.Context) (*gohost.InfoStat, error) { + return &gohost.InfoStat{Hostname: "pve-host", HostID: "hid", KernelArch: runtime.GOARCH}, nil + }, + } + + agent, err := New(Config{ + APIToken: "token", + EnableProxmox: true, + DisableClusterPeerSensors: true, + LogLevel: zerolog.InfoLevel, + Collector: mc, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + if !agent.cfg.DisableClusterPeerSensors { + t.Fatal("expected peer sensor collection opt-out to be preserved") + } +} + func TestNew_AllowsLocalNetworkHTTPPulseURL(t *testing.T) { mc := &mockCollector{ hostInfoFn: func(context.Context) (*gohost.InfoStat, error) { diff --git a/internal/hostagent/cluster_sensors.go b/internal/hostagent/cluster_sensors.go index 16a755dc83..ddc64d5f70 100644 --- a/internal/hostagent/cluster_sensors.go +++ b/internal/hostagent/cluster_sensors.go @@ -36,7 +36,7 @@ const ( // collectClusterSensors discovers Proxmox cluster siblings and collects temperature // data from each via SSH. Returns nil if not in a cluster or no peers are reachable. func (a *Agent) collectClusterSensors(ctx context.Context) []agentshost.ClusterNodeSensors { - if !a.cfg.EnableProxmox { + if !a.cfg.EnableProxmox || a.cfg.DisableClusterPeerSensors { return nil } diff --git a/internal/hostagent/cluster_sensors_test.go b/internal/hostagent/cluster_sensors_test.go index 48b6b76993..d2a43cba3b 100644 --- a/internal/hostagent/cluster_sensors_test.go +++ b/internal/hostagent/cluster_sensors_test.go @@ -182,6 +182,36 @@ func TestCollectClusterSensors_NotProxmox(t *testing.T) { } } +func TestCollectClusterSensors_Disabled(t *testing.T) { + logger := zerolog.New(zerolog.NewConsoleWriter(func(w *zerolog.ConsoleWriter) { + w.Out = os.Stderr + })).Level(zerolog.DebugLevel) + + commandCalled := false + a := &Agent{ + logger: logger, + cfg: Config{ + EnableProxmox: true, + DisableClusterPeerSensors: true, + }, + collector: &mockCollector{ + goos: "linux", + commandCombinedOutputFn: func(ctx context.Context, name string, arg ...string) (string, error) { + commandCalled = true + return "", fmt.Errorf("cluster peer collection must be disabled") + }, + }, + } + + result := a.collectClusterSensors(context.Background()) + if result != nil { + t.Errorf("expected nil when cluster peer sensors are disabled, got %v", result) + } + if commandCalled { + t.Error("cluster peer collection ran despite being disabled") + } +} + func TestCollectClusterSensors_NotLinux(t *testing.T) { logger := zerolog.New(zerolog.NewConsoleWriter(func(w *zerolog.ConsoleWriter) { w.Out = os.Stderr