Support agent retargeting after server moves

This commit is contained in:
pulse-triage[bot] 2026-08-30 16:04:08 +01:00
parent c3faa48973
commit 323da54067
11 changed files with 321 additions and 16 deletions

View file

@ -97,8 +97,10 @@ ENABLE_KUBERNETES="" # Empty means "auto-detect"
ENABLE_PROXMOX="" # Empty means "auto-detect"
PROXMOX_TYPE=""
UPDATE_ONLY="false"
RETARGET_ONLY="false"
UNINSTALL="false"
INSECURE="false"
INSECURE_EXPLICIT="false"
SERVER_FINGERPRINT="${PULSE_SERVER_FINGERPRINT:-}"
OBSERVERS_FILE="${PULSE_OBSERVERS_FILE:-}"
AGENT_ID=""
@ -125,7 +127,7 @@ AGENT_LOG_FILE="" # When set, pass --log-file so the agent's rotating log writer
DEFAULT_STATE_DIR="/var/lib/pulse-agent"
STATE_DIR="$DEFAULT_STATE_DIR" # Persistent state directory (overridden per platform)
STATE_DIR_SOURCE="default" # default, explicit, recovered, or platform
CURL_CA_BUNDLE="" # Path to CA bundle for curl and agent TLS (sets SSL_CERT_FILE)
CURL_CA_BUNDLE="${PULSE_CACERT:-}" # Path to CA bundle for curl and agent TLS (sets SSL_CERT_FILE)
NON_INTERACTIVE="false"
TOKEN_FILE_PATH="" # Path to file containing the token
RUNTIME_TOKEN_FILE="" # Secure token file passed to the installed service
@ -576,6 +578,7 @@ Options:
--safe-profile-rollback Restore the prior collector/helper snapshot from the last committed safe-profile migration
--enroll Exchange bootstrap token for runtime token (deploy wizard)
--update Update an existing agent using saved connection state
--retarget Point an existing agent at --url using saved identity and token
--uninstall Remove the agent
--non-interactive Skip TTY prompts (for automated/scripted installs)
--token-file <path> Read token from file (alternative to --token)
@ -3325,17 +3328,17 @@ recover_connection_state() {
if [[ -z "$REPORT_IP" ]]; then
REPORT_IP=$(read_connection_state_value "$file" "PULSE_REPORT_IP")
fi
if [[ "$INSECURE" != "true" ]]; then
if [[ "${RETARGET_ONLY:-false}" != "true" && "$INSECURE" != "true" ]]; then
local saved_insecure=""
saved_insecure=$(read_connection_state_value "$file" "PULSE_INSECURE_SKIP_VERIFY")
if [[ "$saved_insecure" == "true" ]]; then
INSECURE="true"
fi
fi
if [[ -z "$SERVER_FINGERPRINT" ]]; then
if [[ "${RETARGET_ONLY:-false}" != "true" && -z "$SERVER_FINGERPRINT" ]]; then
SERVER_FINGERPRINT=$(read_connection_state_value "$file" "PULSE_SERVER_FINGERPRINT")
fi
if [[ -z "$CURL_CA_BUNDLE" ]]; then
if [[ "${RETARGET_ONLY:-false}" != "true" && -z "$CURL_CA_BUNDLE" ]]; then
CURL_CA_BUNDLE=$(read_connection_state_value "$file" "PULSE_CACERT")
fi
}
@ -3398,11 +3401,11 @@ apply_recovered_agent_arg_value() {
RECOVERED_AGENT_ARG_STATE="true"
;;
cacert)
if [[ -z "$CURL_CA_BUNDLE" ]]; then CURL_CA_BUNDLE="$value"; fi
if [[ "${RETARGET_ONLY:-false}" != "true" && -z "$CURL_CA_BUNDLE" ]]; then CURL_CA_BUNDLE="$value"; fi
RECOVERED_AGENT_ARG_STATE="true"
;;
server-fingerprint)
if [[ -z "$SERVER_FINGERPRINT" ]]; then SERVER_FINGERPRINT="$value"; fi
if [[ "${RETARGET_ONLY:-false}" != "true" && -z "$SERVER_FINGERPRINT" ]]; then SERVER_FINGERPRINT="$value"; fi
RECOVERED_AGENT_ARG_STATE="true"
;;
observers-file)
@ -3565,7 +3568,9 @@ recover_connection_state_from_arg_stream() {
RECOVERED_AGENT_ARG_STATE="true"
;;
--insecure|-insecure)
INSECURE="true"
if [[ "${RETARGET_ONLY:-false}" != "true" || "${INSECURE_EXPLICIT:-false}" == "true" ]]; then
INSECURE="true"
fi
RECOVERED_AGENT_ARG_STATE="true"
;;
--enable-commands|-enable-commands)
@ -3645,17 +3650,19 @@ recover_connection_state_from_env_stream() {
RECOVERED_AGENT_ENV_STATE="true"
;;
PULSE_INSECURE_SKIP_VERIFY=true)
INSECURE="true"
if [[ "${RETARGET_ONLY:-false}" != "true" || "${INSECURE_EXPLICIT:-false}" == "true" ]]; then
INSECURE="true"
fi
RECOVERED_AGENT_ENV_STATE="true"
;;
PULSE_CACERT=*)
value="${env_line#*=}"
if [[ -z "$CURL_CA_BUNDLE" ]]; then CURL_CA_BUNDLE="$value"; fi
if [[ "${RETARGET_ONLY:-false}" != "true" && -z "$CURL_CA_BUNDLE" ]]; then CURL_CA_BUNDLE="$value"; fi
RECOVERED_AGENT_ENV_STATE="true"
;;
PULSE_SERVER_FINGERPRINT=*)
value="${env_line#*=}"
if [[ -z "$SERVER_FINGERPRINT" ]]; then SERVER_FINGERPRINT="$value"; fi
if [[ "${RETARGET_ONLY:-false}" != "true" && -z "$SERVER_FINGERPRINT" ]]; then SERVER_FINGERPRINT="$value"; fi
RECOVERED_AGENT_ENV_STATE="true"
;;
esac
@ -4090,7 +4097,7 @@ while [[ $# -gt 0 ]]; do
--enable-proxmox=false) ENABLE_PROXMOX="false"; PROXMOX_EXPLICIT="true"; shift ;;
--disable-proxmox) ENABLE_PROXMOX="false"; PROXMOX_EXPLICIT="true"; shift ;;
--proxmox-type) PROXMOX_TYPE="$2"; shift 2 ;;
--insecure) INSECURE="true"; shift ;;
--insecure) INSECURE="true"; INSECURE_EXPLICIT="true"; shift ;;
--cacert) CURL_CA_BUNDLE="$2"; shift 2 ;;
--server-fingerprint) SERVER_FINGERPRINT="$2"; shift 2 ;;
--observers-file) OBSERVERS_FILE="$2"; shift 2 ;;
@ -4111,6 +4118,7 @@ while [[ $# -gt 0 ]]; do
--safe-profile-rollback) SAFE_PROFILE_ACTION="rollback"; shift ;;
--enroll) ENROLL="true"; shift ;;
--update) UPDATE_ONLY="true"; shift ;;
--retarget) RETARGET_ONLY="true"; UPDATE_ONLY="true"; shift ;;
--uninstall) UNINSTALL="true"; shift ;;
--agent-id) AGENT_ID="$2"; shift 2 ;;
--hostname) HOSTNAME_OVERRIDE="$2"; shift 2 ;;
@ -4129,6 +4137,13 @@ while [[ $# -gt 0 ]]; do
esac
done
if [[ "$RETARGET_ONLY" == "true" && -z "$PULSE_URL" ]]; then
fail "--retarget requires the new Pulse endpoint in --url" "$EXIT_MISSING_ARGS"
fi
if [[ "$RETARGET_ONLY" == "true" && "$UNINSTALL" == "true" ]]; then
fail "--retarget cannot be combined with --uninstall" "$EXIT_MISSING_ARGS"
fi
case "$SAFE_PROFILE_ACTION" in
"") ;;
inspect)

View file

@ -889,8 +889,12 @@ func TestInstallSHSupportsSavedStateUpdateMode(t *testing.T) {
script := string(content)
required := []string{
`--update Update an existing agent using saved connection state`,
`--retarget Point an existing agent at --url using saved identity and token`,
`UPDATE_ONLY="false"`,
`RETARGET_ONLY="false"`,
`--update) UPDATE_ONLY="true"; shift ;;`,
`--retarget) RETARGET_ONLY="true"; UPDATE_ONLY="true"; shift ;;`,
`--retarget requires the new Pulse endpoint in --url`,
`if [[ "$UPDATE_ONLY" == "true" || "$UNINSTALL" == "true" ]]; then`,
`lifecycle_conn_env=$(find_connection_state_file || true)`,
`recover_connection_state "$lifecycle_conn_env"`,
@ -927,6 +931,158 @@ func TestInstallSHSupportsSavedStateUpdateMode(t *testing.T) {
}
}
func TestInstallSHRetargetPreservesIdentityWithoutOldEndpointTrust(t *testing.T) {
stateDir := t.TempDir()
tokenPath := filepath.Join(stateDir, "token")
if err := os.WriteFile(tokenPath, []byte("deadbeef\n"), 0600); err != nil {
t.Fatalf("write token fixture: %v", err)
}
connectionPath := filepath.Join(stateDir, "connection.env")
connection := strings.Join([]string{
"PULSE_URL='https://old-pulse.example.test:7655'",
"PULSE_TOKEN_FILE='" + tokenPath + "'",
"PULSE_AGENT_ID='agent-123'",
"PULSE_HOSTNAME='pve-one'",
"PULSE_INSECURE_SKIP_VERIFY='true'",
"PULSE_SERVER_FINGERPRINT='aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'",
"PULSE_CACERT='/etc/pulse/old-ca.pem'",
}, "\n") + "\n"
if err := os.WriteFile(connectionPath, []byte(connection), 0600); err != nil {
t.Fatalf("write connection fixture: %v", err)
}
script := `
PULSE_URL="https://new-pulse.example.test:7655"
PULSE_TOKEN=""
RETARGET_ONLY="true"
INSECURE="false"
SERVER_FINGERPRINT=""
CURL_CA_BUNDLE=""
AGENT_ID=""
HOSTNAME_OVERRIDE=""
REPORT_IP=""
STATE_DIR="/var/lib/pulse-agent"
STATE_DIR_SOURCE="default"
DEFAULT_STATE_DIR="/var/lib/pulse-agent"
TRUENAS_STATE_DIR="/data/pulse-agent"
` + extractInstallShellFunction(t, "read_connection_state_value") + `
` + extractInstallShellFunction(t, "recover_token_from_default_agent_token_file") + `
` + extractInstallShellFunction(t, "recover_connection_state") + `
recover_connection_state "${PULSE_TEST_CONNECTION:?}"
printf 'URL=%s\nTOKEN=%s\nAGENT_ID=%s\nHOSTNAME=%s\nINSECURE=%s\nFINGERPRINT=%s\nCACERT=%s\n' \
"$PULSE_URL" "$PULSE_TOKEN" "$AGENT_ID" "$HOSTNAME_OVERRIDE" "$INSECURE" "$SERVER_FINGERPRINT" "$CURL_CA_BUNDLE"
`
cmd := exec.Command("bash", "-c", script)
cmd.Env = append(os.Environ(), "PULSE_TEST_CONNECTION="+connectionPath)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("bash: %v\n%s", err, out)
}
got := string(out)
for _, needle := range []string{
"URL=https://new-pulse.example.test:7655",
"TOKEN=deadbeef",
"AGENT_ID=agent-123",
"HOSTNAME=pve-one",
"INSECURE=false",
"FINGERPRINT=",
"CACERT=",
} {
if !strings.Contains(got, needle) {
t.Fatalf("retarget state missing %q:\n%s", needle, got)
}
}
if strings.Contains(got, "old-pulse") || strings.Contains(got, "old-ca") || strings.Contains(got, "aaaaaaaa") {
t.Fatalf("retarget carried old endpoint trust or URL:\n%s", got)
}
}
func TestInstallSHRetargetDoesNotRecoverLegacyServiceTrust(t *testing.T) {
script := `
PULSE_URL="https://new-pulse.example.test:7655"
PULSE_TOKEN=""
RETARGET_ONLY="true"
INSECURE="false"
INSECURE_EXPLICIT="false"
SERVER_FINGERPRINT=""
CURL_CA_BUNDLE=""
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=""
ENABLE_COMMANDS="false"
ENROLL="false"
COMMAND_AUTHORITY_SOURCE=""
HEALTH_ADDR=""
HEALTH_ADDR_SET="false"
AGENT_ID=""
HOSTNAME_OVERRIDE=""
REPORT_IP=""
STATE_DIR="/var/lib/pulse-agent"
STATE_DIR_SOURCE="default"
OBSERVERS_FILE=""
KUBE_INCLUDE_ALL_PODS="false"
KUBE_INCLUDE_ALL_DEPLOYMENTS="false"
DISK_EXCLUDES=()
DISK_INCLUDES=()
` + 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, "recover_connection_state_from_env_stream") + `
recover_connection_state_from_arg_stream <<'ARGS'
/usr/local/bin/pulse-agent
--url=https://old-pulse.example.test:7655
--token=deadbeef
--agent-id=agent-123
--hostname=pve-one
--insecure
--cacert=/etc/pulse/old-ca.pem
--server-fingerprint=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
ARGS
recover_connection_state_from_env_stream <<'ENV'
PULSE_INSECURE_SKIP_VERIFY=true
PULSE_CACERT=/etc/pulse/older-ca.pem
PULSE_SERVER_FINGERPRINT=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
ENV
printf 'URL=%s\nTOKEN=%s\nAGENT_ID=%s\nHOSTNAME=%s\nINSECURE=%s\nFINGERPRINT=%s\nCACERT=%s\n' \
"$PULSE_URL" "$PULSE_TOKEN" "$AGENT_ID" "$HOSTNAME_OVERRIDE" "$INSECURE" "$SERVER_FINGERPRINT" "$CURL_CA_BUNDLE"
`
out, err := exec.Command("bash", "-c", script).CombinedOutput()
if err != nil {
t.Fatalf("bash: %v\n%s", err, out)
}
got := string(out)
for _, needle := range []string{
"URL=https://new-pulse.example.test:7655",
"TOKEN=deadbeef",
"AGENT_ID=agent-123",
"HOSTNAME=pve-one",
"INSECURE=false",
"FINGERPRINT=",
"CACERT=",
} {
if !strings.Contains(got, needle) {
t.Fatalf("retarget service recovery missing %q:\n%s", needle, got)
}
}
if strings.Contains(got, "old-pulse") || strings.Contains(got, "old-ca") || strings.Contains(got, "aaaaaaaa") || strings.Contains(got, "bbbbbbbb") {
t.Fatalf("retarget carried old service trust or URL:\n%s", got)
}
}
func TestInstallSHRecoversV5ProcessArgsForSavedStateUpdate(t *testing.T) {
script := `
fail() { echo "FAIL:$1"; exit 99; }