From 323da54067338098dbd92ada551cd6f1d5d61e1d Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:04:08 +0100 Subject: [PATCH] Support agent retargeting after server moves --- docs/CONFIGURATION.md | 5 + docs/UNIFIED_AGENT.md | 29 ++++ frontend-modern/public/docs/CONFIGURATION.md | 5 + frontend-modern/public/docs/UNIFIED_AGENT.md | 29 ++++ frontend-modern/scripts/sync-public-docs.mjs | 6 +- .../Settings/BackupTransferDialogs.tsx | 15 +- .../__tests__/BackupTransferDialogs.test.tsx | 41 +++++ .../config_export_import_handlers.go | 4 + .../configapi/config_handlers_admin_test.go | 10 ++ scripts/install.sh | 37 +++-- scripts/installtests/install_sh_test.go | 156 ++++++++++++++++++ 11 files changed, 321 insertions(+), 16 deletions(-) create mode 100644 frontend-modern/src/components/Settings/__tests__/BackupTransferDialogs.test.tsx diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 436c4c911..4b30522e8 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -370,6 +370,11 @@ You can auto-import an encrypted backup on first startup. This is useful for aut > **Note**: `PULSE_INIT_CONFIG_URL` is only supported by the hidden `pulse config auto-import` command, not by the server startup auto-import. +Configuration import restores server-side agent credentials, but it cannot +change the primary Pulse URL persisted on remote agents. When restoring onto a +server with a different address, retarget those agents after import. See +[Moving Pulse to a new address](UNIFIED_AGENT.md#moving-pulse-to-a-new-address). + ### Developer/Test Overrides (Environment Variables) These are primarily for development or test harnesses and should not be used in production. diff --git a/docs/UNIFIED_AGENT.md b/docs/UNIFIED_AGENT.md index 7968c0114..079acafba 100644 --- a/docs/UNIFIED_AGENT.md +++ b/docs/UNIFIED_AGENT.md @@ -31,6 +31,35 @@ Run it on the host that already has the v5 `pulse-agent` service to replace the binary and service configuration in place; do not uninstall the old service first unless you are intentionally removing that host from Pulse. +### Moving Pulse to a new address + +Configuration export/import restores the server-side agent records and API +tokens. It cannot rewrite the primary Pulse URL on remote machines because +agents initiate the connection. Prefer a stable DNS name for the primary URL +so replacing the Pulse host does not require an agent migration. + +After importing the configuration on a Pulse server with a different address, +retarget each existing standard Linux agent from that agent machine: + +```bash +curl -fsSL https://pulse.example.com:7655/install.sh | \ + sudo bash -s -- --retarget --url https://pulse.example.com:7655 +``` + +The retarget operation recovers the existing token, agent ID, enabled +collectors, and other service options. It does not carry the old endpoint's +TLS bypass, custom CA, or certificate fingerprint to the new address. Supply +`--cacert`, `--server-fingerprint`, or (only on a trusted network) +`--insecure` explicitly when the new endpoint requires it. The script must +come from the new server so it supports the retarget operation. A newly +generated full installation command from **Settings → Infrastructure → Install +on a host** remains the fallback. + +On Windows, run the full generated PowerShell installation command from the +new Pulse server as Administrator, including the desired collector options. +Do not expect the configuration import itself to make agent-only machines +appear at the new address. + An installed agent has one **primary** Pulse URL and token. The primary is the only server allowed to supply remote configuration, commands, enrollment, or updates. The same collection can also be sent to explicitly configured, diff --git a/frontend-modern/public/docs/CONFIGURATION.md b/frontend-modern/public/docs/CONFIGURATION.md index 436c4c911..4b30522e8 100644 --- a/frontend-modern/public/docs/CONFIGURATION.md +++ b/frontend-modern/public/docs/CONFIGURATION.md @@ -370,6 +370,11 @@ You can auto-import an encrypted backup on first startup. This is useful for aut > **Note**: `PULSE_INIT_CONFIG_URL` is only supported by the hidden `pulse config auto-import` command, not by the server startup auto-import. +Configuration import restores server-side agent credentials, but it cannot +change the primary Pulse URL persisted on remote agents. When restoring onto a +server with a different address, retarget those agents after import. See +[Moving Pulse to a new address](UNIFIED_AGENT.md#moving-pulse-to-a-new-address). + ### Developer/Test Overrides (Environment Variables) These are primarily for development or test harnesses and should not be used in production. diff --git a/frontend-modern/public/docs/UNIFIED_AGENT.md b/frontend-modern/public/docs/UNIFIED_AGENT.md index 7968c0114..079acafba 100644 --- a/frontend-modern/public/docs/UNIFIED_AGENT.md +++ b/frontend-modern/public/docs/UNIFIED_AGENT.md @@ -31,6 +31,35 @@ Run it on the host that already has the v5 `pulse-agent` service to replace the binary and service configuration in place; do not uninstall the old service first unless you are intentionally removing that host from Pulse. +### Moving Pulse to a new address + +Configuration export/import restores the server-side agent records and API +tokens. It cannot rewrite the primary Pulse URL on remote machines because +agents initiate the connection. Prefer a stable DNS name for the primary URL +so replacing the Pulse host does not require an agent migration. + +After importing the configuration on a Pulse server with a different address, +retarget each existing standard Linux agent from that agent machine: + +```bash +curl -fsSL https://pulse.example.com:7655/install.sh | \ + sudo bash -s -- --retarget --url https://pulse.example.com:7655 +``` + +The retarget operation recovers the existing token, agent ID, enabled +collectors, and other service options. It does not carry the old endpoint's +TLS bypass, custom CA, or certificate fingerprint to the new address. Supply +`--cacert`, `--server-fingerprint`, or (only on a trusted network) +`--insecure` explicitly when the new endpoint requires it. The script must +come from the new server so it supports the retarget operation. A newly +generated full installation command from **Settings → Infrastructure → Install +on a host** remains the fallback. + +On Windows, run the full generated PowerShell installation command from the +new Pulse server as Administrator, including the desired collector options. +Do not expect the configuration import itself to make agent-only machines +appear at the new address. + An installed agent has one **primary** Pulse URL and token. The primary is the only server allowed to supply remote configuration, commands, enrollment, or updates. The same collection can also be sent to explicitly configured, diff --git a/frontend-modern/scripts/sync-public-docs.mjs b/frontend-modern/scripts/sync-public-docs.mjs index 5eb107d16..3e06b8815 100644 --- a/frontend-modern/scripts/sync-public-docs.mjs +++ b/frontend-modern/scripts/sync-public-docs.mjs @@ -10,9 +10,13 @@ const targetDocsDir = path.join(frontendRoot, 'public', 'docs'); const shippedDocs = [ { source: path.join(repoRoot, 'docs', 'README.md'), target: 'README.md' }, - { source: path.join(repoRoot, 'docs', 'MIGRATION_UNIFIED_NAV.md'), target: 'MIGRATION_UNIFIED_NAV.md' }, + { + source: path.join(repoRoot, 'docs', 'MIGRATION_UNIFIED_NAV.md'), + target: 'MIGRATION_UNIFIED_NAV.md', + }, { source: path.join(repoRoot, 'docs', 'PRIVACY.md'), target: 'PRIVACY.md' }, { source: path.join(repoRoot, 'docs', 'CONFIGURATION.md'), target: 'CONFIGURATION.md' }, + { source: path.join(repoRoot, 'docs', 'UNIFIED_AGENT.md'), target: 'UNIFIED_AGENT.md' }, { source: path.join(repoRoot, 'docs', 'PROXY_AUTH.md'), target: 'PROXY_AUTH.md' }, { source: path.join(repoRoot, 'docs', 'TRUENAS.md'), target: 'TRUENAS.md' }, { source: path.join(repoRoot, 'SECURITY.md'), target: 'SECURITY.md' }, diff --git a/frontend-modern/src/components/Settings/BackupTransferDialogs.tsx b/frontend-modern/src/components/Settings/BackupTransferDialogs.tsx index 68618ee5b..d0cf8024a 100644 --- a/frontend-modern/src/components/Settings/BackupTransferDialogs.tsx +++ b/frontend-modern/src/components/Settings/BackupTransferDialogs.tsx @@ -270,10 +270,17 @@ export const BackupTransferDialogs: Component = (pro
-

- Warning: Importing will replace all current configuration. This - action cannot be undone. -

+
+

+ Warning: Importing will replace all current configuration. This + action cannot be undone. +

+

+ Agent migration: The backup restores server-side agent records + and credentials, but it cannot change the Pulse URL stored on remote agents. If + this server has a different address, retarget each agent after the import. +

+
diff --git a/frontend-modern/src/components/Settings/__tests__/BackupTransferDialogs.test.tsx b/frontend-modern/src/components/Settings/__tests__/BackupTransferDialogs.test.tsx new file mode 100644 index 000000000..1a0e2eb13 --- /dev/null +++ b/frontend-modern/src/components/Settings/__tests__/BackupTransferDialogs.test.tsx @@ -0,0 +1,41 @@ +import { cleanup, render, screen } from '@solidjs/testing-library'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { BackupTransferDialogs } from '../BackupTransferDialogs'; + +describe('BackupTransferDialogs', () => { + afterEach(() => { + cleanup(); + }); + + it('discloses that configuration import cannot retarget remote agents', () => { + render(() => ( + null} + exportPassphrase={() => ''} + setExportPassphrase={vi.fn()} + useCustomPassphrase={() => false} + setUseCustomPassphrase={vi.fn()} + importPassphrase={() => 'secure-passphrase'} + setImportPassphrase={vi.fn()} + importFile={() => null} + setImportFile={vi.fn()} + showExportDialog={() => false} + showImportDialog={() => true} + showApiTokenModal={() => false} + apiTokenInput={() => ''} + setApiTokenInput={vi.fn()} + handleExport={vi.fn()} + handleImport={vi.fn()} + closeExportDialog={vi.fn()} + closeImportDialog={vi.fn()} + closeApiTokenModal={vi.fn()} + handleApiTokenAuthenticate={vi.fn()} + /> + )); + + expect(screen.getByText('Agent migration:')).toBeInTheDocument(); + expect( + screen.getByText(/it cannot change the Pulse URL stored on remote agents/i), + ).toBeInTheDocument(); + }); +}); diff --git a/internal/api/configapi/config_export_import_handlers.go b/internal/api/configapi/config_export_import_handlers.go index dcbc63257..c40fb898a 100644 --- a/internal/api/configapi/config_export_import_handlers.go +++ b/internal/api/configapi/config_export_import_handlers.go @@ -207,5 +207,9 @@ func (h *ConfigHandlers) handleImportConfig(w http.ResponseWriter, r *http.Reque json.NewEncoder(w).Encode(map[string]interface{}{ "status": "success", "message": "Configuration imported successfully", + "remoteAgentEndpoints": map[string]bool{ + "reconfigured": false, + "actionRequiredIfServerAddressChanged": true, + }, }) } diff --git a/internal/api/configapi/config_handlers_admin_test.go b/internal/api/configapi/config_handlers_admin_test.go index eb4e285ff..3bb559a23 100644 --- a/internal/api/configapi/config_handlers_admin_test.go +++ b/internal/api/configapi/config_handlers_admin_test.go @@ -174,6 +174,16 @@ func TestHandleImportConfig(t *testing.T) { if resp["status"] != "success" { t.Errorf("expected status success, got %v", resp["status"]) } + remoteEndpoints, ok := resp["remoteAgentEndpoints"].(map[string]interface{}) + if !ok { + t.Fatalf("expected remote agent endpoint import disclosure, got %T", resp["remoteAgentEndpoints"]) + } + if remoteEndpoints["reconfigured"] != false { + t.Errorf("configuration import must not claim to reconfigure remote agents: %v", remoteEndpoints) + } + if remoteEndpoints["actionRequiredIfServerAddressChanged"] != true { + t.Errorf("expected changed-address action disclosure, got %v", remoteEndpoints) + } }, }, { diff --git a/scripts/install.sh b/scripts/install.sh index 4b8de971c..43b9aa4c1 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -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 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) diff --git a/scripts/installtests/install_sh_test.go b/scripts/installtests/install_sh_test.go index 243bd1ac7..a4a1a50b3 100644 --- a/scripts/installtests/install_sh_test.go +++ b/scripts/installtests/install_sh_test.go @@ -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; }