Harden root agent service defaults

This commit is contained in:
rcourtman 2026-05-05 13:03:13 +01:00
parent 81b31e4d3b
commit cf103ca9fe
10 changed files with 223 additions and 43 deletions

View file

@ -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

View file

@ -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)
}
},
},
}

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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": [

View file

@ -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

View file

@ -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

View file

@ -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 ;;

View file

@ -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) {