From b503ee6fd3139738dc395d94daa3ba70973d357b Mon Sep 17 00:00:00 2001 From: rcourtman Date: Tue, 7 Jul 2026 09:31:21 +0100 Subject: [PATCH] Fix legacy agent update token recovery Refs #1515 --- .../v6/internal/subsystems/agent-lifecycle.md | 17 ++-- .../subsystems/deployment-installability.md | 12 ++- scripts/install.sh | 33 +++++++ scripts/installtests/install_sh_test.go | 99 +++++++++++++++++++ 4 files changed, 151 insertions(+), 10 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 9d8105ac0..6b4b47f3a 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -269,12 +269,17 @@ process or unit may seed later recovery attempts, but it must not be logged or treated as recovered update state. That fallback must still run when an operator supplies the update URL explicitly but token, identity, feature-flag, or trust continuity remains recoverable only from a legacy v5 process or -service. Because v5.1.x agents were launched by Go flag parsing, that legacy -fallback must treat single-dash and double-dash agent flag spellings as the -same recovered state while preserving the same fail-closed URL-plus-token -threshold. Windows stale-agent update commands remain on the existing -token-gated install transport until the Windows installer owns an equivalent -saved-state update mode. +service. Legacy v5.1.x Linux services that omitted `--token` and +`--token-file` because the Go agent read `/var/lib/pulse-agent/token` +implicitly may complete recovery from that installer state-dir/default token +file only after a running process, service unit, or saved state supplies local +agent connection context; the token file alone must not hide a missing +control-plane URL. Because v5.1.x agents were launched by Go flag parsing, +that legacy fallback must treat single-dash and double-dash agent flag +spellings as the same recovered state while preserving the same fail-closed +URL-plus-token threshold. Windows stale-agent update commands remain on the +existing token-gated install transport until the Windows installer owns an +equivalent saved-state update mode. Agent Fleet Doctor diagnostics extend that same read-only lifecycle triage surface: `GET /api/agents/diagnostics` may explain stale versions, missing reports, profile deployment drift, expected Docker/Kubernetes telemetry gaps, diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index a5ad78fa7..15f6549c4 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -279,10 +279,14 @@ TLS floor in the dynamic config. raw token in process arguments. That fallback remains required when the operator supplies `--url` on the update command but token, identity, feature-flag, or trust continuity still exists only in legacy process or - service state. Because v5.1.x agents used Go's single-dash flag spelling, - the installer-owned recovery path must accept both single-dash and - double-dash forms for recovered agent args without weakening the existing - missing-state failure behavior. + service state. Legacy v5.1.x Linux services that relied on the Go agent's + implicit `/var/lib/pulse-agent/token` fallback may recover that default + token file only after local process, service, or saved-state context has + supplied the agent connection shape; the token file alone is not enough to + convert a missing-state update into a new install. Because v5.1.x agents + used Go's single-dash flag spelling, the installer-owned recovery path must + accept both single-dash and double-dash forms for recovered agent args + without weakening the existing missing-state failure behavior. The shell installer must disclose `--enable-commands` as Pulse command execution, disabled by default, and must name both Patrol actions and Proxmox LXC Docker inventory as the operator-visible reasons to enable it. diff --git a/scripts/install.sh b/scripts/install.sh index abefba1bf..56b7ed506 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1479,6 +1479,28 @@ read_connection_state_value() { ' "$file" 2>/dev/null || true } +recover_token_from_default_agent_token_file() { + local token_path="" + local recovered_token="" + + if [[ -n "$PULSE_TOKEN" ]]; then + return 0 + fi + + # v5.1.x Linux services could omit --token and --token-file because the + # Go agent read this default file itself. + for token_path in "${STATE_DIR%/}/token" "/var/lib/pulse-agent/token" "$TRUENAS_STATE_DIR/token"; do + [[ -n "$token_path" && -f "$token_path" ]] || continue + recovered_token=$(cat "$token_path" 2>/dev/null || true) + if [[ -n "$recovered_token" ]]; then + PULSE_TOKEN="$recovered_token" + return 0 + fi + done + + return 1 +} + recover_connection_state() { local file="$1" @@ -1495,6 +1517,9 @@ recover_connection_state() { PULSE_TOKEN=$(cat "$saved_token_file") fi fi + if [[ -z "$PULSE_TOKEN" && -n "$PULSE_URL" ]]; then + recover_token_from_default_agent_token_file || true + fi if [[ -z "$AGENT_ID" ]]; then AGENT_ID=$(read_connection_state_value "$file" "PULSE_AGENT_ID") fi @@ -1705,6 +1730,10 @@ recover_connection_state_from_arg_stream() { esac done + if [[ "$RECOVERED_AGENT_ARG_STATE" == "true" && -z "$PULSE_TOKEN" && -n "$PULSE_URL" ]]; then + recover_token_from_default_agent_token_file || true + fi + [[ "$RECOVERED_AGENT_ARG_STATE" == "true" ]] && recovered_connection_state_ready } @@ -1755,6 +1784,10 @@ recover_connection_state_from_env_stream() { esac done + if [[ "$RECOVERED_AGENT_ENV_STATE" == "true" && -z "$PULSE_TOKEN" && -n "$PULSE_URL" ]]; then + recover_token_from_default_agent_token_file || true + fi + [[ "$RECOVERED_AGENT_ENV_STATE" == "true" ]] && recovered_connection_state_ready } diff --git a/scripts/installtests/install_sh_test.go b/scripts/installtests/install_sh_test.go index 518c78880..fc9cf553e 100644 --- a/scripts/installtests/install_sh_test.go +++ b/scripts/installtests/install_sh_test.go @@ -644,6 +644,7 @@ func TestInstallSHSupportsSavedStateUpdateMode(t *testing.T) { `recover_connection_state_from_running_agent`, `recover_connection_state_from_systemd_unit`, `recover_connection_state_from_arg_stream`, + `recover_token_from_default_agent_token_file() {`, `normalize_recovered_agent_arg_key() {`, `-url|-pulse-url|-token|-token-file|-interval|-agent-id|-hostname|-cacert|-health-addr|-state-dir|-kubeconfig|-proxmox-type|-disk-exclude)`, `--enable-host|-enable-host|--enable-host=true|-enable-host=true)`, @@ -702,6 +703,7 @@ func TestInstallSHRecoversV5ProcessArgsForSavedStateUpdate(t *testing.T) { ` + extractInstallShellFunction(t, "normalize_recovered_agent_arg_key") + ` ` + extractInstallShellFunction(t, "apply_recovered_agent_arg_value") + ` ` + extractInstallShellFunction(t, "recovered_connection_state_ready") + ` +` + extractInstallShellFunction(t, "recover_token_from_default_agent_token_file") + ` ` + extractInstallShellFunction(t, "recover_connection_state_from_arg_stream") + ` ` + extractInstallShellFunction(t, "build_exec_arg_items") + ` ` + extractInstallShellFunction(t, "join_exec_arg_items") + ` @@ -785,6 +787,7 @@ func TestInstallSHRejectsPartialRecoveredProcessConnectionState(t *testing.T) { ` + extractInstallShellFunction(t, "normalize_recovered_agent_arg_key") + ` ` + extractInstallShellFunction(t, "apply_recovered_agent_arg_value") + ` ` + extractInstallShellFunction(t, "recovered_connection_state_ready") + ` +` + extractInstallShellFunction(t, "recover_token_from_default_agent_token_file") + ` ` + extractInstallShellFunction(t, "recover_connection_state_from_arg_stream") + ` if recover_connection_state_from_arg_stream <<'ARGS' /usr/local/bin/pulse-agent @@ -821,6 +824,100 @@ ARGS } } +func TestInstallSHRecoversLegacyDefaultTokenFileForSavedStateUpdate(t *testing.T) { + stateDir := t.TempDir() + if err := os.WriteFile(filepath.Join(stateDir, "token"), []byte("deadbeef\n"), 0600); err != nil { + t.Fatalf("write legacy token file: %v", err) + } + + script := ` + fail() { echo "FAIL:$1"; exit 99; } + PULSE_URL="http://192.168.2.96:7655" + PULSE_TOKEN="" + INTERVAL="30s" + INTERVAL_EXPLICIT="false" + ENABLE_HOST="true" + HOST_EXPLICIT="false" + ENABLE_DOCKER="" + DOCKER_EXPLICIT="false" + ENABLE_KUBERNETES="" + KUBERNETES_EXPLICIT="false" + KUBECONFIG_PATH="" + ENABLE_PROXMOX="" + PROXMOX_EXPLICIT="false" + PROXMOX_TYPE="" + INSECURE="true" + ENABLE_COMMANDS="false" + ENROLL="false" + HEALTH_ADDR="" + HEALTH_ADDR_SET="false" + AGENT_ID="" + HOSTNAME_OVERRIDE="" + STATE_DIR="${PULSE_TEST_STATE_DIR:?}" + TRUENAS_STATE_DIR="${PULSE_TEST_STATE_DIR:?}/truenas" + CURL_CA_BUNDLE="" + KUBE_INCLUDE_ALL_PODS="false" + KUBE_INCLUDE_ALL_DEPLOYMENTS="false" + DISK_EXCLUDES=() + RUNTIME_TOKEN_FILE="${STATE_DIR}/token" +` + extractInstallShellFunction(t, "strip_recovered_arg_quotes") + ` +` + extractInstallShellFunction(t, "normalize_recovered_agent_arg_key") + ` +` + extractInstallShellFunction(t, "apply_recovered_agent_arg_value") + ` +` + extractInstallShellFunction(t, "recovered_connection_state_ready") + ` +` + extractInstallShellFunction(t, "recover_token_from_default_agent_token_file") + ` +` + extractInstallShellFunction(t, "recover_connection_state_from_arg_stream") + ` +` + extractInstallShellFunction(t, "build_exec_arg_items") + ` +` + extractInstallShellFunction(t, "join_exec_arg_items") + ` +` + extractInstallShellFunction(t, "build_exec_args") + ` + if recover_connection_state_from_arg_stream <<'ARGS' +/usr/local/bin/pulse-agent +-url +http://192.168.2.96:7655 +-interval +30s +-enable-host +-enable-docker +-agent-id +machine-1 +-hostname +docker1 +ARGS + then + echo "READY" + else + echo "NOT_READY" + fi + build_exec_args + printf 'URL=%s\nTOKEN=%s\nDOCKER=%s\nAGENT_ID=%s\nHOSTNAME=%s\nEXEC_ARGS=%s\n' \ + "$PULSE_URL" "$PULSE_TOKEN" "$ENABLE_DOCKER" "$AGENT_ID" "$HOSTNAME_OVERRIDE" "$EXEC_ARGS" + ` + + cmd := exec.Command("bash", "-c", script) + cmd.Env = append(os.Environ(), "PULSE_TEST_STATE_DIR="+stateDir) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("bash: %v\n%s", err, out) + } + got := string(out) + for _, needle := range []string{ + "READY", + "URL=http://192.168.2.96:7655", + "TOKEN=deadbeef", + "DOCKER=true", + "AGENT_ID=machine-1", + "HOSTNAME=docker1", + "--token-file", + stateDir + "/token", + } { + if !strings.Contains(got, needle) { + t.Fatalf("legacy default token recovery missing %q:\n%s", needle, got) + } + } + if strings.Contains(got, "--token deadbeef") { + t.Fatalf("legacy default token recovery leaked raw token into service args:\n%s", got) + } +} + func TestInstallSHCombinesRecoveredProcessArgsAndEnvConnectionState(t *testing.T) { script := ` PULSE_URL="" @@ -853,6 +950,7 @@ func TestInstallSHCombinesRecoveredProcessArgsAndEnvConnectionState(t *testing.T ` + extractInstallShellFunction(t, "normalize_recovered_agent_arg_key") + ` ` + extractInstallShellFunction(t, "apply_recovered_agent_arg_value") + ` ` + extractInstallShellFunction(t, "recovered_connection_state_ready") + ` +` + extractInstallShellFunction(t, "recover_token_from_default_agent_token_file") + ` ` + extractInstallShellFunction(t, "recover_connection_state_from_arg_stream") + ` ` + extractInstallShellFunction(t, "recover_connection_state_from_env_stream") + ` if recover_connection_state_from_arg_stream <<'ARGS' @@ -930,6 +1028,7 @@ func TestInstallSHUpdateModeMergesExplicitURLWithRunningV5ProcessState(t *testin ` + extractInstallShellFunction(t, "apply_recovered_agent_arg_value") + ` ` + extractInstallShellFunction(t, "recovered_connection_state_ready") + ` ` + extractInstallShellFunction(t, "update_connection_state_incomplete") + ` +` + extractInstallShellFunction(t, "recover_token_from_default_agent_token_file") + ` ` + extractInstallShellFunction(t, "recover_connection_state_from_arg_stream") + ` ` + extractInstallShellFunction(t, "build_exec_arg_items") + ` ` + extractInstallShellFunction(t, "join_exec_arg_items") + `