diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 3c05d660d..4b681e6b2 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -259,9 +259,12 @@ command. If saved state is missing and the Unix shell installer falls back to legacy running-process or service-unit recovery, recovered connection state is usable only when both the control-plane URL and token are present. A URL-only process or unit may seed later recovery attempts, but it must not be logged or -treated as recovered update state. Windows stale-agent update commands remain -on the existing token-gated install transport until the Windows installer owns -an equivalent saved-state update mode. +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. 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, @@ -1088,7 +1091,7 @@ surface and no new `internal/api/` lifecycle handler. 7. Keep legacy Unified Agent compatibility names explicitly secondary when touching shared `internal/api/` runtime helpers: the legacy host-route family and `host-agent:*` scope names may remain as ingress or migration aliases, but they must not retake primary ownership in router state, live runtime scope checks, handler commentary, or operator-facing guidance. 8. Add or change the unified agent CLI entrypoint, version/help exit semantics, or startup argument/error routing through `cmd/pulse-agent/main.go`. The CLI entrypoint owns propagation of persistence context into runtime-owned helpers. When installer-selected state roots differ from the default, `cmd/pulse-agent/main.go` must pass that exact `StateDir` through both the host-agent runtime and updater startup paths instead of letting one path silently fall back to `/var/lib/pulse-agent`. - The same runtime-owned boundary also owns Pulse control-plane URL validation for agent startup, remote config, updater continuity, and command transport. Public control-plane hostnames remain HTTPS/WSS, but self-hosted local control planes may use plain HTTP/WS when the host is loopback, a private or link-local IP, a single-label LAN name, or a local DNS suffix such as `.local`, `.lan`, `.home`, `.home.arpa`, or `.internal`; installer-persisted local HTTP URLs must not be accepted by one runtime path and rejected by another. + The same runtime-owned boundary also owns Pulse control-plane URL validation for agent startup, remote config, updater continuity, and command transport. Public control-plane hostnames remain HTTPS/WSS, but self-hosted local control planes may use plain HTTP/WS when the host is loopback, a private, link-local, or carrier-grade NAT IP, a single-label LAN name, or a local DNS suffix such as `.local`, `.lan`, `.home`, `.home.arpa`, or `.internal`; installer-persisted local HTTP URLs must not be accepted by one runtime path and rejected by another. The unified agent CLI copy follows the same command-execution vocabulary as the install surface. `cmd/pulse-agent/main.go` may keep the `--enable-commands` flag name for compatibility, but the help text and inline comments must describe command execution as Pulse command execution for Patrol actions and governed Proxmox LXC Docker inventory rather than reviving AI auto-fix language. The unified agent CLI copy also owns operator-facing Docker / Podman runtime labels. `cmd/pulse-agent/main.go` may keep the historical @@ -2622,8 +2625,9 @@ normalization used by `internal/hostagent/agent.go`, `internal/hostagent/commands.go`, `internal/agentupdate/update.go`, `internal/dockeragent/agent.go`, `internal/kubernetesagent/agent.go`, and `internal/remoteconfig/client.go`. Those runtime clients may keep self-hosted -`http://` or `ws://` only for loopback, private/link-local IP, single-label, or -local DNS Pulse origins; public remote Pulse URLs must still use HTTPS/WSS. +`http://` or `ws://` only for loopback, private/link-local IP, +carrier-grade NAT `100.64.0.0/10`, single-label, or local DNS Pulse origins; +public remote Pulse URLs must still use HTTPS/WSS. `InsecureSkipVerify` may relax certificate verification on TLS transport; it must not reopen public plaintext HTTP for updater, websocket, reporting, or remote-config paths. @@ -3794,7 +3798,10 @@ service command. It may report recovery success only after URL and token are both available, even when those values were assembled from different legacy sources; partial URL-only recovery must fall through to the explicit missing-state error so operators are not told the update state was recovered -when the installer still cannot reconnect. +when the installer still cannot reconnect. That fallback must also run after +saved-state parsing when explicit update arguments supplied the URL but legacy +v5 process or service state is still the only source for the token, identity, +feature flags, or trust posture. That same lifecycle ownership must cover service control too: the installer may still choose different platform adapters, but stop/restart semantics for the managed agent must route through shared installer helpers instead of being diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 122ccaffb..bdcc789a8 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -276,7 +276,10 @@ TLS floor in the dynamic config. feature flags, identity, and trust posture from the running `pulse-agent` process or its systemd service definition, then persist the upgraded runtime back through the v6 token-file service-argument path rather than keeping the - raw token in process arguments. + 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. 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. @@ -377,7 +380,9 @@ TLS floor in the dynamic config. `--url`, `--token`, feature flags, and identity arguments even when `connection.env` is missing or incomplete. The upgraded service must be rendered through the shared exec-argument builder and use the secure - `--token-file` runtime path. + `--token-file` runtime path, and explicit update URL arguments must not + suppress legacy recovery of the remaining connection, identity, feature, or + trust fields. Deployment bootstrap token behavior remains a deployment-installability trust boundary even when the handler is API-owned. `internal/api/deploy_handlers.go` must preserve server-derived `owner_user_id` lineage on bootstrap tokens and @@ -1903,7 +1908,10 @@ is not usable connection state and must not be logged or treated as recovered. Fallback recovery may merge URL and token across process args, environment, and systemd unit data, but it may report success only once both values are present; otherwise the update command must fall through to the explicit missing-state -error instead of implying recovery succeeded. +error instead of implying recovery succeeded. Explicit update arguments that +provide only the URL must still run legacy process/service recovery before this +decision so v5 agents without `connection.env` can recover their token and +identity. That same installer ownership now also applies to service lifecycle control: upgrade, reinstall, and platform-specific start/restart flows may not each carry their own stop/start command sequence for the same agent runtime. diff --git a/internal/securityutil/httpurl_test.go b/internal/securityutil/httpurl_test.go index 36afb1747..c529cb5e1 100644 --- a/internal/securityutil/httpurl_test.go +++ b/internal/securityutil/httpurl_test.go @@ -50,6 +50,9 @@ func TestIsLocalNetworkHost(t *testing.T) { "10.0.0.5", "172.16.4.20", "192.168.1.15", + "100.64.0.1", + "100.100.100.5", + "100.127.255.254", "fe80::1", "pulse", "ct-pulse.home", @@ -66,7 +69,7 @@ func TestIsLocalNetworkHost(t *testing.T) { }) t.Run("false", func(t *testing.T) { - cases := []string{"", "example.com", "pulse.example.com", "8.8.8.8"} + cases := []string{"", "example.com", "pulse.example.com", "8.8.8.8", "100.63.255.255", "100.128.0.1"} for _, tc := range cases { if IsLocalNetworkHost(tc) { t.Fatalf("expected public/non-local host for %q", tc) @@ -147,6 +150,16 @@ func TestNormalizePulseHTTPBaseURLWithOptions(t *testing.T) { t.Fatalf("NormalizePulseHTTPBaseURLWithOptions() = %q", got.String()) } + got, err = NormalizePulseHTTPBaseURLWithOptions("http://100.100.100.5:7655/", PulseURLValidationOptions{ + AllowLocalNetworkHTTP: true, + }) + if err != nil { + t.Fatalf("NormalizePulseHTTPBaseURLWithOptions(CGNAT) error = %v", err) + } + if got.String() != "http://100.100.100.5:7655" { + t.Fatalf("NormalizePulseHTTPBaseURLWithOptions(CGNAT) = %q", got.String()) + } + got, err = NormalizePulseHTTPBaseURLWithOptions("http://ct-pulse.home:7655/", PulseURLValidationOptions{ AllowLocalNetworkHTTP: true, }) @@ -287,6 +300,16 @@ func TestNormalizePulseWebSocketBaseURLWithOptions(t *testing.T) { t.Fatalf("NormalizePulseWebSocketBaseURLWithOptions() = %q", got.String()) } + got, err = NormalizePulseWebSocketBaseURLWithOptions("http://100.100.100.5:7655/pulse", PulseURLValidationOptions{ + AllowLocalNetworkHTTP: true, + }) + if err != nil { + t.Fatalf("NormalizePulseWebSocketBaseURLWithOptions(CGNAT) error = %v", err) + } + if got.String() != "ws://100.100.100.5:7655/pulse" { + t.Fatalf("NormalizePulseWebSocketBaseURLWithOptions(CGNAT) = %q", got.String()) + } + if _, err := NormalizePulseWebSocketBaseURLWithOptions("http://pulse.example.com:7655/pulse", PulseURLValidationOptions{ AllowLocalNetworkHTTP: true, }); err == nil || !strings.Contains(err.Error(), "must use https/wss unless host is loopback or local/private") { diff --git a/pkg/securityutil/httpurl.go b/pkg/securityutil/httpurl.go index a4851e5c3..a6a7cf75c 100644 --- a/pkg/securityutil/httpurl.go +++ b/pkg/securityutil/httpurl.go @@ -127,7 +127,7 @@ func IsLocalNetworkHost(host string) bool { } if ip := net.ParseIP(normalized); ip != nil { - return ip.IsPrivate() || ip.IsLinkLocalUnicast() + return ip.IsPrivate() || ip.IsLinkLocalUnicast() || isCarrierGradeNATIPv4(ip) } if !strings.Contains(normalized, ".") { @@ -143,6 +143,15 @@ func IsLocalNetworkHost(host string) bool { return false } +func isCarrierGradeNATIPv4(ip net.IP) bool { + v4 := ip.To4() + if v4 == nil { + return false + } + + return v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127 +} + // PulseURLValidationOptions controls optional relaxations for Pulse runtime // transports. The default zero value preserves the strict production contract. type PulseURLValidationOptions struct { diff --git a/scripts/install.sh b/scripts/install.sh index beaede81c..29d3526dd 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1595,6 +1595,10 @@ recovered_connection_state_ready() { [[ -n "$PULSE_URL" && -n "$PULSE_TOKEN" ]] } +update_connection_state_incomplete() { + [[ -z "$PULSE_URL" || -z "$PULSE_TOKEN" || -z "$AGENT_ID" || -z "$HOSTNAME_OVERRIDE" || -z "$CURL_CA_BUNDLE" || "$INSECURE" != "true" ]] +} + recover_connection_state_from_arg_stream() { local arg="" local pending_key="" @@ -2012,14 +2016,14 @@ fi # installer reuses the canonical saved connection state instead of asking the # operator to mint a new install token for a host Pulse already knows about. if [[ "$UPDATE_ONLY" == "true" ]]; then - if [[ -z "$PULSE_URL" || -z "$PULSE_TOKEN" || -z "$AGENT_ID" || -z "$HOSTNAME_OVERRIDE" || -z "$CURL_CA_BUNDLE" || "$INSECURE" != "true" ]]; then + if update_connection_state_incomplete; then local update_conn_env="" update_conn_env=$(find_connection_state_file || true) if [[ -n "$update_conn_env" ]]; then log_info "Recovering connection details from ${update_conn_env}..." recover_connection_state "$update_conn_env" fi - if [[ -z "$PULSE_URL" || -z "$PULSE_TOKEN" ]]; then + if update_connection_state_incomplete; then recover_connection_state_from_existing_agent || true fi if [[ -n "$PULSE_URL" && -n "$PULSE_TOKEN" ]]; then diff --git a/scripts/installtests/install_sh_test.go b/scripts/installtests/install_sh_test.go index 5b614dc9b..7e0b479ea 100644 --- a/scripts/installtests/install_sh_test.go +++ b/scripts/installtests/install_sh_test.go @@ -646,8 +646,10 @@ func TestInstallSHSupportsSavedStateUpdateMode(t *testing.T) { `recover_connection_state_from_arg_stream`, `recover_connection_state_from_env_stream`, `recovered_connection_state_ready() {`, + `update_connection_state_incomplete() {`, `[[ "$RECOVERED_AGENT_ARG_STATE" == "true" ]] && recovered_connection_state_ready`, `[[ "$RECOVERED_AGENT_ENV_STATE" == "true" ]] && recovered_connection_state_ready`, + `if update_connection_state_incomplete; then`, `recover_connection_state_from_existing_agent || true`, `if [[ -n "$PULSE_URL" && -n "$PULSE_TOKEN" ]]; then`, `recover_agent_id_from_state_file() {`, @@ -885,6 +887,115 @@ ENV } } +func TestInstallSHUpdateModeMergesExplicitURLWithRunningV5ProcessState(t *testing.T) { + script := ` + fail() { echo "FAIL:$1"; exit 99; } + log_info() { :; } + UPDATE_ONLY="true" + 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="/var/lib/pulse-agent" + CURL_CA_BUNDLE="" + KUBE_INCLUDE_ALL_PODS="false" + KUBE_INCLUDE_ALL_DEPLOYMENTS="false" + DISK_EXCLUDES=() + RUNTIME_TOKEN_FILE="/var/lib/pulse-agent/token" +` + extractInstallShellFunction(t, "strip_recovered_arg_quotes") + ` +` + extractInstallShellFunction(t, "apply_recovered_agent_arg_value") + ` +` + extractInstallShellFunction(t, "recovered_connection_state_ready") + ` +` + extractInstallShellFunction(t, "update_connection_state_incomplete") + ` +` + 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") + ` + find_connection_state_file() { return 1; } + recover_agent_id_from_state_file() { return 1; } + recover_connection_state_from_existing_agent() { + recover_connection_state_from_arg_stream <<'ARGS' +/usr/local/bin/pulse-agent +--url +http://192.168.2.96:7655 +--token +deadbeef +--interval +30s +--enable-host +--enable-docker +--agent-id +machine-1 +--hostname +docker1 +ARGS + } + + if [[ "$UPDATE_ONLY" == "true" ]]; then + if update_connection_state_incomplete; then + update_conn_env=$(find_connection_state_file || true) + if [[ -n "$update_conn_env" ]]; then + recover_connection_state "$update_conn_env" + fi + if update_connection_state_incomplete; then + recover_connection_state_from_existing_agent || true + fi + if [[ -n "$PULSE_URL" && -n "$PULSE_TOKEN" ]]; then + echo "READY" + elif [[ -z "$PULSE_URL" || -z "$PULSE_TOKEN" ]]; then + fail "No existing Pulse Agent connection state found. Use the install command instead." + fi + if [[ -z "$AGENT_ID" ]]; then + AGENT_ID=$(recover_agent_id_from_state_file || true) + fi + fi + fi + build_exec_args + printf 'URL=%s\nTOKEN=%s\nDOCKER=%s\nDOCKER_EXPLICIT=%s\nAGENT_ID=%s\nHOSTNAME=%s\nEXEC_ARGS=%s\n' \ + "$PULSE_URL" "$PULSE_TOKEN" "$ENABLE_DOCKER" "$DOCKER_EXPLICIT" "$AGENT_ID" "$HOSTNAME_OVERRIDE" "$EXEC_ARGS" + ` + + out, err := exec.Command("bash", "-c", script).CombinedOutput() + if err != nil { + t.Fatalf("bash: %v\n%s", err, out) + } + got := string(out) + required := []string{ + "READY", + "URL=http://192.168.2.96:7655", + "TOKEN=deadbeef", + "DOCKER=true", + "DOCKER_EXPLICIT=true", + "AGENT_ID=machine-1", + "HOSTNAME=docker1", + "--token-file /var/lib/pulse-agent/token", + } + for _, needle := range required { + if !strings.Contains(got, needle) { + t.Fatalf("reported v5 update recovery missing %q:\n%s", needle, got) + } + } + if strings.Contains(got, "--token deadbeef") { + t.Fatalf("reported v5 update recovery leaked raw token into service args:\n%s", got) + } +} + func TestInstallSHUsesCanonicalServiceLifecycleHelpers(t *testing.T) { content, err := os.ReadFile(repoFile("scripts", "install.sh")) if err != nil {