mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
Harden root agent service defaults
This commit is contained in:
parent
81b31e4d3b
commit
cf103ca9fe
10 changed files with 223 additions and 43 deletions
|
|
@ -778,9 +778,15 @@ func loadConfig(args []string, getenv func(string) string) (Config, error) {
|
|||
defaultEnableProxmox = utils.ParseBool(envEnableProxmox)
|
||||
}
|
||||
|
||||
healthAddrDisabledByEnv := false
|
||||
defaultHealthAddr := envHealthAddr
|
||||
if defaultHealthAddr == "" {
|
||||
defaultHealthAddr = ":9191"
|
||||
switch strings.ToLower(defaultHealthAddr) {
|
||||
case "off", "none", "disabled":
|
||||
defaultHealthAddr = ""
|
||||
healthAddrDisabledByEnv = true
|
||||
}
|
||||
if defaultHealthAddr == "" && !healthAddrDisabledByEnv {
|
||||
defaultHealthAddr = "127.0.0.1:9191"
|
||||
}
|
||||
|
||||
// Flags
|
||||
|
|
|
|||
|
|
@ -1660,6 +1660,30 @@ func TestLoadConfig_Comprehensive(t *testing.T) {
|
|||
if cfg.EnableDocker {
|
||||
t.Error("EnableDocker should be false by default")
|
||||
}
|
||||
if cfg.HealthAddr != "127.0.0.1:9191" {
|
||||
t.Errorf("HealthAddr: got %q, want loopback default", cfg.HealthAddr)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "health addr can be opened explicitly",
|
||||
args: []string{"-token", "T", "-health-addr", ":9191"},
|
||||
validate: func(t *testing.T, cfg Config) {
|
||||
if cfg.HealthAddr != ":9191" {
|
||||
t.Errorf("HealthAddr: got %q, want :9191", cfg.HealthAddr)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "health addr can be disabled by env",
|
||||
args: []string{"-token", "T"},
|
||||
env: map[string]string{
|
||||
"PULSE_HEALTH_ADDR": "off",
|
||||
},
|
||||
validate: func(t *testing.T, cfg Config) {
|
||||
if cfg.HealthAddr != "" {
|
||||
t.Errorf("HealthAddr: got %q, want disabled", cfg.HealthAddr)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,9 +22,18 @@ root read access:
|
|||
- update from signed release assets rather than arbitrary branch snapshots.
|
||||
|
||||
The agent is primarily an outbound reporter to your Pulse server. By default it
|
||||
also exposes local health and Prometheus endpoints on `:9191`; set
|
||||
`--health-addr 127.0.0.1:9191` to bind that surface to loopback, or
|
||||
`--health-addr ""` to disable it when you do not scrape agent-local metrics.
|
||||
binds the health and Prometheus endpoints to `127.0.0.1:9191`, so a root agent
|
||||
does not expose that HTTP surface to the network unless you explicitly opt in.
|
||||
Set `--health-addr :9191` only when you intentionally scrape the agent from
|
||||
another host. Use `--health-addr ""` or `PULSE_HEALTH_ADDR=off` to disable the
|
||||
listener.
|
||||
|
||||
Generated Linux/systemd units also include conservative sandboxing such as
|
||||
`NoNewPrivileges=true`, `PrivateTmp=true`, kernel/control-group write
|
||||
protection, a private umask, and setuid/personality restrictions. Those
|
||||
directives reduce service blast radius while keeping the filesystem and device
|
||||
access needed for full host telemetry, Proxmox token setup, SMART, Docker, and
|
||||
NAS integrations.
|
||||
|
||||
Command execution is disabled by default. It can be enabled with
|
||||
`--enable-commands`, `PULSE_ENABLE_COMMANDS=true`, or the centralized agent
|
||||
|
|
|
|||
|
|
@ -98,10 +98,11 @@ curl -fsSL http://<pulse-ip>:7655/install.sh | \
|
|||
| `--disable-ceph` | `PULSE_DISABLE_CEPH` | Disable local Ceph status polling | `false` |
|
||||
| `--tag` | `PULSE_TAGS` | Apply tags (repeatable or CSV) | *(none)* |
|
||||
| `--log-level` | `LOG_LEVEL` | Log verbosity (`debug`, `info`, `warn`, `error`) | `info` |
|
||||
| `--health-addr` | `PULSE_HEALTH_ADDR` | Health/metrics server address | `:9191` |
|
||||
| `--health-addr` | `PULSE_HEALTH_ADDR` | Health/metrics server address | `127.0.0.1:9191` |
|
||||
|
||||
Use `--health-addr 127.0.0.1:9191` when only local Prometheus scraping needs
|
||||
the health/metrics endpoint, or `--health-addr ""` to disable that listener.
|
||||
Use `--health-addr :9191` only when another host must scrape the
|
||||
health/metrics endpoint over the network. Use `--health-addr ""` or
|
||||
`PULSE_HEALTH_ADDR=off` to disable that listener.
|
||||
|
||||
**Token resolution order**: `--token` → `--token-file` → `PULSE_TOKEN` → `/var/lib/pulse-agent/token`.
|
||||
|
||||
|
|
@ -301,7 +302,7 @@ readinessProbe:
|
|||
|
||||
### Disable Health Server
|
||||
|
||||
Set `--health-addr=""` or `PULSE_HEALTH_ADDR=""` to disable the health/metrics server.
|
||||
Set `--health-addr=""` or `PULSE_HEALTH_ADDR=off` to disable the health/metrics server. Set `--health-addr :9191` when network Prometheus scraping is intentional.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
# Agent Lifecycle Root Agent Hardening Record
|
||||
|
||||
- Date: `2026-05-05`
|
||||
- Lane: `L16`
|
||||
- Trigger: Proxmox community concern about root Pulse agent deployments
|
||||
|
||||
## Context
|
||||
|
||||
Operators asked whether deploying Pulse agents as `root` on Proxmox, VMs, and
|
||||
containers creates avoidable risk for read-only monitoring. The canonical
|
||||
full-telemetry Linux agent still needs root-equivalent local access for SMART,
|
||||
temperature, Docker/Podman socket, Proxmox-local, and NAS/platform data, but
|
||||
the shipped defaults did not need to expose the agent health/metrics HTTP
|
||||
surface on every interface.
|
||||
|
||||
## Outcome
|
||||
|
||||
The root-agent boundary now has tighter defaults without claiming a supported
|
||||
non-root full-telemetry profile:
|
||||
|
||||
- `pulse-agent` binds health and Prometheus endpoints to `127.0.0.1:9191` by
|
||||
default instead of all interfaces;
|
||||
- explicit network scraping remains opt-in through `--health-addr :9191`;
|
||||
- the agent can disable that listener with `--health-addr ""` or
|
||||
`PULSE_HEALTH_ADDR=off`;
|
||||
- `scripts/install.sh` passes explicit health-address choices into the
|
||||
installed service and verifies custom or disabled health listeners without
|
||||
falsely reporting an unhealthy install;
|
||||
- generated Linux/systemd agent units include conservative sandboxing
|
||||
directives (`NoNewPrivileges`, `PrivateTmp`, kernel/control-group write
|
||||
protections, private umask, setuid/personality restrictions, and native
|
||||
syscall architecture restriction) that reduce blast radius while preserving
|
||||
the filesystem/device access required by supported full host telemetry.
|
||||
|
||||
The customer-facing security documentation now distinguishes API-only Proxmox
|
||||
monitoring from full host-agent telemetry and documents the new listener and
|
||||
systemd hardening defaults.
|
||||
|
|
@ -3360,6 +3360,11 @@
|
|||
"path": "docs/release-control/v6/internal/records/agent-lifecycle-ai-discovery-adapter-origin-2026-05-01.md",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "docs/release-control/v6/internal/records/agent-lifecycle-root-agent-hardening-2026-05-05.md",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "docs/release-control/v6/internal/records/unified-agent-v5-upgrade-continuity-2026-03-12.md",
|
||||
|
|
@ -5052,21 +5057,7 @@
|
|||
],
|
||||
"coverage_gaps": [],
|
||||
"candidate_lanes": [],
|
||||
"work_claims": [
|
||||
{
|
||||
"id": "codex-lane-l16",
|
||||
"agent_id": "codex",
|
||||
"summary": "Harden Linux agent service defaults from Proxmox root-agent trust signal",
|
||||
"target_id": "v6-product-lane-expansion",
|
||||
"claimed_at": "2026-05-05T11:53:57Z",
|
||||
"heartbeat_at": "2026-05-05T11:53:57Z",
|
||||
"expires_at": "2026-05-05T13:53:57Z",
|
||||
"work_item": {
|
||||
"kind": "lane",
|
||||
"id": "L16"
|
||||
}
|
||||
}
|
||||
],
|
||||
"work_claims": [],
|
||||
"open_decisions": [],
|
||||
"source_of_truth_file": "docs/release-control/v6/internal/SOURCE_OF_TRUTH.md",
|
||||
"resolved_decisions": [
|
||||
|
|
|
|||
|
|
@ -765,6 +765,14 @@ profile and assignment columns, but embedded table framing must route through
|
|||
|
||||
## Current State
|
||||
|
||||
Linux agent privilege hardening is now part of the installer/runtime contract.
|
||||
The supported full-telemetry systemd agent may still run as `root`, but
|
||||
`cmd/pulse-agent/main.go` must bind health/metrics to loopback by default,
|
||||
`scripts/install.sh` must preserve explicit health-address disable/open choices
|
||||
in the rendered service, and generated systemd units must keep conservative
|
||||
sandboxing in place unless a future telemetry requirement records a narrower
|
||||
exception.
|
||||
|
||||
Generated TrueNAS CORE rc.d service scripts must give `/usr/sbin/daemon -r` a
|
||||
supervisor pidfile with `-P`, keep the child pid in a separate diagnostic
|
||||
pidfile, and stop legacy child-pidfile installs by resolving the child back to
|
||||
|
|
|
|||
|
|
@ -334,6 +334,13 @@ server-side update execution surfaces.
|
|||
|
||||
## Current State
|
||||
|
||||
The shell-installer boundary carries root-agent service hardening for Linux
|
||||
installs. Installer-rendered agent units must keep the health/metrics listener
|
||||
loopback by default, allow explicit disablement or network-scrape opt-in
|
||||
through `--health-addr` / `PULSE_HEALTH_ADDR`, and preserve conservative
|
||||
systemd sandboxing alongside the root full-telemetry service model instead of
|
||||
silently reopening an all-interface root HTTP listener.
|
||||
|
||||
Generated TrueNAS CORE rc.d services must use `/usr/sbin/daemon -r` with a
|
||||
supervisor pidfile (`-P`) separate from the child pidfile (`-p`) and must stop
|
||||
older child-pidfile installs by killing the daemon supervisor before the child
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
# --disk-exclude <pattern> Exclude mount points matching pattern (repeatable)
|
||||
# --insecure Skip TLS certificate verification
|
||||
# --enable-commands Enable AI command execution on agent (disabled by default)
|
||||
# --health-addr <addr> Health/metrics listener address (default: 127.0.0.1:9191, use "" to disable)
|
||||
# --uninstall Remove the agent
|
||||
#
|
||||
# Auto-Detection:
|
||||
|
|
@ -76,6 +77,11 @@ INSECURE="false"
|
|||
AGENT_ID=""
|
||||
HOSTNAME_OVERRIDE=""
|
||||
ENABLE_COMMANDS="false"
|
||||
HEALTH_ADDR="${PULSE_HEALTH_ADDR:-}"
|
||||
HEALTH_ADDR_SET="false"
|
||||
if [[ -n "${PULSE_HEALTH_ADDR+x}" ]]; then
|
||||
HEALTH_ADDR_SET="true"
|
||||
fi
|
||||
ENROLL="false"
|
||||
KUBECONFIG_PATH="" # Path to kubeconfig file for Kubernetes monitoring
|
||||
KUBE_INCLUDE_ALL_PODS="false"
|
||||
|
|
@ -278,6 +284,7 @@ Options:
|
|||
--insecure Skip TLS verification (auto-enabled for http:// URLs)
|
||||
--cacert <path> Custom CA certificate for TLS (used by curl and agent)
|
||||
--enable-commands Enable AI command execution
|
||||
--health-addr <addr> Health/metrics listener address (default: 127.0.0.1:9191; use "" to disable)
|
||||
--enroll Exchange bootstrap token for runtime token (deploy wizard)
|
||||
--uninstall Remove the agent
|
||||
--non-interactive Skip TTY prompts (for automated/scripted installs)
|
||||
|
|
@ -319,7 +326,8 @@ restore_selinux_contexts() {
|
|||
|
||||
# --- Post-Start Health Verification ---
|
||||
# After starting the agent service, poll its readiness endpoint to verify it
|
||||
# actually started. The agent exposes /readyz on :9191 once modules are initialized.
|
||||
# actually started. The agent exposes /readyz on the configured health address
|
||||
# once modules are initialized. The default is 127.0.0.1:9191.
|
||||
verify_agent_server_registration() {
|
||||
local lookup_hostname="${HOSTNAME_OVERRIDE}"
|
||||
local lookup_resp=""
|
||||
|
|
@ -346,8 +354,48 @@ verify_agent_server_registration() {
|
|||
return 1
|
||||
}
|
||||
|
||||
resolve_agent_health_url() {
|
||||
if [[ "$HEALTH_ADDR_SET" == "true" && -z "$HEALTH_ADDR" ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
local addr="${HEALTH_ADDR:-127.0.0.1:9191}"
|
||||
local ipv6_any_prefix="[::]:"
|
||||
case "$addr" in
|
||||
:*) addr="127.0.0.1${addr}" ;;
|
||||
0.0.0.0:*) addr="127.0.0.1:${addr#0.0.0.0:}" ;;
|
||||
esac
|
||||
if [[ "$addr" == "$ipv6_any_prefix"* ]]; then
|
||||
addr="[::1]:${addr#$ipv6_any_prefix}"
|
||||
fi
|
||||
|
||||
printf 'http://%s/readyz\n' "$addr"
|
||||
}
|
||||
|
||||
agent_process_running() {
|
||||
if command -v pgrep >/dev/null 2>&1; then
|
||||
# Use -x (exact match) if supported, otherwise fall back to -f.
|
||||
pgrep -x "${BINARY_NAME}" >/dev/null 2>&1
|
||||
local pgrep_rc=$?
|
||||
if [ $pgrep_rc -eq 0 ]; then
|
||||
return 0
|
||||
elif [ $pgrep_rc -ge 2 ]; then
|
||||
pgrep -f "${BINARY_NAME}" >/dev/null 2>&1 && return 0
|
||||
fi
|
||||
else
|
||||
# shellcheck disable=SC2009
|
||||
# Use bracket trick ([p]ulse-agent) to prevent grep from matching itself.
|
||||
local grep_pattern="[${BINARY_NAME:0:1}]${BINARY_NAME:1}"
|
||||
if ps -e -o comm= 2>/dev/null | grep -q "$grep_pattern" || ps aux 2>/dev/null | grep -q "$grep_pattern"; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
verify_agent_started() {
|
||||
local health_url="http://127.0.0.1:9191/readyz"
|
||||
local health_url=""
|
||||
local max_iterations=8
|
||||
local interval=2
|
||||
local iteration=0
|
||||
|
|
@ -358,6 +406,29 @@ verify_agent_started() {
|
|||
# Brief pause to let the agent process spawn (especially for background starts like Unraid)
|
||||
sleep 2
|
||||
|
||||
health_url="$(resolve_agent_health_url || true)"
|
||||
if [[ -z "$health_url" ]]; then
|
||||
while [ $iteration -lt $max_iterations ]; do
|
||||
if agent_process_running; then
|
||||
if verify_agent_server_registration; then
|
||||
log_info "Agent process is running and registered with Pulse."
|
||||
else
|
||||
log_warn "Agent process is running, but server registration was not confirmed yet."
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
sleep $interval
|
||||
iteration=$((iteration + 1))
|
||||
done
|
||||
|
||||
log_warn "Agent process is not running!"
|
||||
if [ -f "$log_file" ]; then
|
||||
log_warn "Last log lines:"
|
||||
tail -5 "$log_file" 2>/dev/null | while IFS= read -r line; do log_warn " $line"; done
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
|
||||
while [ $iteration -lt $max_iterations ]; do
|
||||
# Check the readiness endpoint first — this is the definitive signal
|
||||
if curl -sf --max-time 2 "$health_url" >/dev/null 2>&1; then
|
||||
|
|
@ -372,23 +443,8 @@ verify_agent_started() {
|
|||
# If curl failed, check whether the process is still alive.
|
||||
# Use pgrep where available, fall back to ps + grep.
|
||||
local agent_running=false
|
||||
if command -v pgrep >/dev/null 2>&1; then
|
||||
# Use -x (exact match) if supported, otherwise fall back to -f
|
||||
pgrep -x "${BINARY_NAME}" >/dev/null 2>&1
|
||||
local pgrep_rc=$?
|
||||
if [ $pgrep_rc -eq 0 ]; then
|
||||
agent_running=true
|
||||
elif [ $pgrep_rc -ge 2 ]; then
|
||||
# Exit code >= 2 means bad option — -x not supported, try -f
|
||||
pgrep -f "${BINARY_NAME}" >/dev/null 2>&1 && agent_running=true
|
||||
fi
|
||||
else
|
||||
# shellcheck disable=SC2009
|
||||
# Use bracket trick ([p]ulse-agent) to prevent grep from matching itself
|
||||
local grep_pattern="[${BINARY_NAME:0:1}]${BINARY_NAME:1}"
|
||||
if ps -e -o comm= 2>/dev/null | grep -q "$grep_pattern" || ps aux 2>/dev/null | grep -q "$grep_pattern"; then
|
||||
agent_running=true
|
||||
fi
|
||||
if agent_process_running; then
|
||||
agent_running=true
|
||||
fi
|
||||
|
||||
if [ "$agent_running" = "false" ] && [ $iteration -ge 3 ]; then
|
||||
|
|
@ -664,6 +720,15 @@ Type=simple
|
|||
ExecStart=${exec_path} ${exec_args}${env_line}
|
||||
Restart=always
|
||||
RestartSec=5s${user_line}${log_lines}
|
||||
UMask=0077
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
LockPersonality=true
|
||||
RestrictSUIDSGID=true
|
||||
SystemCallArchitectures=native
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
|
@ -1165,6 +1230,7 @@ build_exec_arg_items() {
|
|||
if [[ -n "$PROXMOX_TYPE" ]]; then EXEC_ARG_ITEMS+=(--proxmox-type "$PROXMOX_TYPE"); fi
|
||||
if [[ "$INSECURE" == "true" ]]; then EXEC_ARG_ITEMS+=(--insecure); fi
|
||||
if [[ "$ENABLE_COMMANDS" == "true" ]]; then EXEC_ARG_ITEMS+=(--enable-commands); fi
|
||||
if [[ "$HEALTH_ADDR_SET" == "true" ]]; then EXEC_ARG_ITEMS+=(--health-addr "$HEALTH_ADDR"); fi
|
||||
if [[ "$ENROLL" == "true" ]]; then EXEC_ARG_ITEMS+=(--enroll); fi
|
||||
if [[ "$KUBE_INCLUDE_ALL_PODS" == "true" ]]; then EXEC_ARG_ITEMS+=(--kube-include-all-pods); fi
|
||||
if [[ "$KUBE_INCLUDE_ALL_DEPLOYMENTS" == "true" ]]; then EXEC_ARG_ITEMS+=(--kube-include-all-deployments); fi
|
||||
|
|
@ -1398,6 +1464,7 @@ while [[ $# -gt 0 ]]; do
|
|||
--insecure) INSECURE="true"; shift ;;
|
||||
--cacert) CURL_CA_BUNDLE="$2"; shift 2 ;;
|
||||
--enable-commands) ENABLE_COMMANDS="true"; shift ;;
|
||||
--health-addr) HEALTH_ADDR="$2"; HEALTH_ADDR_SET="true"; shift 2 ;;
|
||||
--enroll) ENROLL="true"; shift ;;
|
||||
--uninstall) UNINSTALL="true"; shift ;;
|
||||
--agent-id) AGENT_ID="$2"; shift 2 ;;
|
||||
|
|
|
|||
|
|
@ -70,6 +70,36 @@ func TestInstallSHAutoDetectProxmoxKeepsRuntimeTypeUnpinned(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestInstallSHAgentServiceSecurityDefaults(t *testing.T) {
|
||||
content, err := os.ReadFile(repoFile("scripts", "install.sh"))
|
||||
if err != nil {
|
||||
t.Fatalf("read install.sh: %v", err)
|
||||
}
|
||||
|
||||
script := string(content)
|
||||
required := []string{
|
||||
`HEALTH_ADDR="${PULSE_HEALTH_ADDR:-}"`,
|
||||
`if [[ -n "${PULSE_HEALTH_ADDR+x}" ]]; then`,
|
||||
`--health-addr <addr> Health/metrics listener address (default: 127.0.0.1:9191; use "" to disable)`,
|
||||
`if [[ "$HEALTH_ADDR_SET" == "true" ]]; then EXEC_ARG_ITEMS+=(--health-addr "$HEALTH_ADDR"); fi`,
|
||||
`--health-addr) HEALTH_ADDR="$2"; HEALTH_ADDR_SET="true"; shift 2 ;;`,
|
||||
`UMask=0077`,
|
||||
`NoNewPrivileges=true`,
|
||||
`PrivateTmp=true`,
|
||||
`ProtectKernelTunables=true`,
|
||||
`ProtectKernelModules=true`,
|
||||
`ProtectControlGroups=true`,
|
||||
`LockPersonality=true`,
|
||||
`RestrictSUIDSGID=true`,
|
||||
`SystemCallArchitectures=native`,
|
||||
}
|
||||
for _, needle := range required {
|
||||
if !strings.Contains(script, needle) {
|
||||
t.Fatalf("install.sh missing agent service security default: %s", needle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnectionEnvRecovery verifies the canonical helper logic that parses
|
||||
// connection.env without using shell source (to prevent injection).
|
||||
func TestConnectionEnvRecovery(t *testing.T) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue