diff --git a/docs/AGENT_SECURITY.md b/docs/AGENT_SECURITY.md index 25400b25d..a0f9f7b0a 100644 --- a/docs/AGENT_SECURITY.md +++ b/docs/AGENT_SECURITY.md @@ -121,6 +121,15 @@ signature, and pass that same tag to `bash install.sh --version`. Agent self-updates still verify checksum headers, and release builds require signatures when a trusted update key is embedded. +The first automatic hop from an already-installed v5 `pulse-agent` to v6 is +performed by the v5 updater. That updater verifies TLS by default, requires the +server-provided SHA-256 checksum, validates executable magic, enforces the size +limit, and swaps atomically, but it does not yet have the v6 Ed25519 signature +requirement or downloaded-binary `--self-test`. For that migration hop, use +HTTPS or a trusted local network. In high-assurance environments, reinstall the +v6 `pulse-agent` through the signed installer path instead of relying on the +automatic v5-to-v6 first hop over plain HTTP. + ## Self-Update Security The agent's self-update mechanism is critical for security and stability. To prevent supply chain attacks or compromised update servers from distributing malicious or broken agents, Pulse employs a rigorous verification process. diff --git a/docs/UNIFIED_AGENT.md b/docs/UNIFIED_AGENT.md index c7c1624ae..eeb14973b 100644 --- a/docs/UNIFIED_AGENT.md +++ b/docs/UNIFIED_AGENT.md @@ -248,6 +248,14 @@ The unified agent automatically checks for updates every hour. When a new versio 5. Replaces itself atomically (with backup) 6. Restarts with the same configuration +When an already-installed v5 `pulse-agent` moves to v6, the first automatic hop +is performed by the v5 updater. That hop verifies TLS by default, the SHA-256 +checksum, executable magic, size limits, and atomic replacement, but the newer +v6 signature and `--self-test` checks apply only after the agent has landed on +v6. Use HTTPS or a trusted local network for v5-to-v6 automatic migration. For +high-assurance environments, reinstall the v6 `pulse-agent` through the signed +installer path instead of relying on a plain-HTTP first hop. + To disable auto-updates: ```bash # During installation diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 0b288d7a0..422fce94d 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -162,15 +162,17 @@ Server update planning is part of the same lifecycle contract. The System Updates plan must surface a structured upgrade-readiness verdict before an operator installs a v6 update, and the backend apply route must enforce a `blocked` verdict before starting the updater: server update path, registered -agent continuity, and agent reporting token scope must be derived from the -existing updater, connections ledger, and API token state rather than from -docs-only guidance or a parallel migration registry. v5 or legacy agents that -are still reporting must be treated as compatible with the v6 reporting -boundary, while stale agents, missing reported versions, missing agent -reporting scope, or expired agent tokens must be called out before the update -starts. Root `install.sh` v5-to-v6 upgrades must also inspect local token -metadata before binary replacement and warn when the shell path cannot prove -agent-token continuity. +agent continuity, v5 agent migration transport security, and agent reporting +token scope must be derived from the existing updater, connections ledger, and +API token state rather than from docs-only guidance or a parallel migration +registry. v5 or legacy agents that are still reporting must be treated as +compatible with the v6 reporting boundary, while still surfacing that their +first automatic hop uses the v5 updater before v6 signature and downloaded +binary self-test protections apply. Stale agents, missing reported versions, +missing agent reporting scope, or expired agent tokens must be called out +before the update starts. Root `install.sh` v5-to-v6 upgrades must also inspect +local token metadata before binary replacement and warn when the shell path +cannot prove agent-token continuity. Agent lifecycle and fleet-operation surfaces may consume `POST /api/actions/plan` for resource capability planning, but the action plan @@ -2528,6 +2530,13 @@ The updater/runtime surfaces must preserve the one-shot `updated_from` continuity handoff and the non-TLS continuity path for supported self-hosted installs, so upgrade-safe agent behavior does not drift between install, restart, and reconnect paths. +The v5-to-v6 automatic migration boundary must name its first-hop trust model +explicitly: an already-installed v5 `pulse-agent` may use its v5 updater to +reach v6, but release readiness and operator docs must warn that this hop +depends on HTTPS or trusted local-network transport until the v6 signature and +downloaded-binary self-test protections are installed. High-assurance +environments must be pointed at signed v6 installer reinstall instead of a +plain-HTTP first hop. That same shared agent transport boundary must not force operators to choose between public-CA trust and blanket TLS disablement. `cmd/pulse-agent/main.go`, `internal/hostagent/`, `internal/agentupdate/`, and adjacent remote-config diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index cd71bc012..5c43e47de 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -499,9 +499,13 @@ payload shape change when the portal presents compact client rows. they actually need. 53. `internal/api/updates.go` shared with `deployment-installability`: update handlers are both a deployment-installability control surface and a canonical API payload contract boundary. Update-plan responses own the structured readiness verdict for server - updater capability, rollback support, agent continuity, and agent reporting - token scope. That verdict is part of the update-plan API contract, not a - settings-only migration registry. `POST /api/updates/apply` must derive + updater capability, rollback support, agent continuity, v5 agent migration + transport security, and agent reporting token scope. That verdict is part + of the update-plan API contract, not a settings-only migration registry. + When v5 or legacy agents are present, readiness must preserve the + `agent-migration-security` warning that automatic first-hop migration + depends on HTTPS or trusted local-network transport, with signed-installer + reinstall as the high-assurance path. `POST /api/updates/apply` must derive the requested target version through the shared update-target validation path, recompute readiness from live backend state, and reject `blocked` verdicts before update execution starts. @@ -3765,9 +3769,12 @@ The same update-plan contract now carries an optional `readiness` verdict. Backend handlers own the `ready` / `attention` / `blocked` status vocabulary and per-check payload shape, while frontend clients must preserve that payload unchanged so settings surfaces can disable automatic install on blocked checks -without inventing a parallel migration state model. The UI disablement is only -presentation: backend apply handlers must still enforce `blocked` readiness -server-side. +without inventing a parallel migration state model. The same payload must also +carry the v5-to-v6 first-hop transport warning when legacy agents are present, +because the first automatic hop runs through the already-installed v5 updater +before v6 signature and downloaded-binary self-test protections apply. The UI +disablement is only presentation: backend apply handlers must still enforce +`blocked` readiness server-side. Those same install-command payloads now also carry a non-TLS continuity contract: when Pulse returns a plain `http://` base URL for a generated agent install command, the command must include `--insecure` so the installed agent diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index f69e42fd1..2fdfef9dd 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -237,9 +237,9 @@ recovery scope, or a storage/recovery-owned secret source. Update-plan readiness payloads and apply-route readiness enforcement are adjacent shared API context only. Storage and recovery surfaces may observe the resulting update state if a future settings flow links to recovery - preparation, but they must not reinterpret agent-token or server-update - readiness checks as backup freshness, restore capability, or storage-provider - health. + preparation, but they must not reinterpret agent-token, + agent-migration-security, or server-update readiness checks as backup + freshness, restore capability, or storage-provider health. Shared API-token transport helpers may be consumed by storage/recovery- adjacent flows, but `owner_user_id` remains server-authored token identity metadata; storage/recovery extensions must not pass metadata that authors diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 6475d8960..a0031ba6b 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -15019,3 +15019,21 @@ func TestContract_DockerPodmanAdminCopyUsesPulseAgentModuleIdentity(t *testing.T } } } + +func TestContract_UpdateReadinessIncludesV5AgentMigrationSecurityGuidance(t *testing.T) { + source, err := os.ReadFile("update_readiness.go") + if err != nil { + t.Fatalf("read update_readiness.go: %v", err) + } + text := string(source) + for _, required := range []string{ + `ID: "agent-migration-security"`, + "v5 agents can auto-update to v6, but the first hop depends on trusted transport.", + "Use HTTPS, or keep the Pulse-to-agent migration path on a trusted local network", + "For high-assurance environments, reinstall the v6 pulse-agent through the signed installer path", + } { + if !strings.Contains(text, required) { + t.Errorf("update_readiness.go must preserve v5-to-v6 migration security guidance %q", required) + } + } +} diff --git a/internal/api/update_readiness.go b/internal/api/update_readiness.go index 889471f0b..8e36a0841 100644 --- a/internal/api/update_readiness.go +++ b/internal/api/update_readiness.go @@ -38,6 +38,7 @@ func buildUpdateReadiness(in updateReadinessInputs) *updates.UpdateReadiness { checks := []updates.UpdateReadinessCheck{ buildUpdatePathReadinessCheck(in.plan), buildAgentContinuityReadinessCheck(in.hosts, in.targetVersion, now), + buildAgentMigrationSecurityReadinessCheck(in.hosts), buildAgentTokenReadinessCheck(in.cfg, len(in.hosts), now), } @@ -134,7 +135,7 @@ func buildAgentContinuityReadinessCheck(hosts []models.Host, targetVersion strin active := 0 pendingOrStale := 0 behind := 0 - legacy := 0 + legacy := countLegacyAgents(hosts) unknownVersion := 0 for _, conn := range connections { if conn.Type != ConnectionTypeAgent { @@ -153,12 +154,6 @@ func buildAgentContinuityReadinessCheck(hosts []models.Host, targetVersion strin unknownVersion++ } } - for _, host := range hosts { - if host.IsLegacy || looksLikePreV6Version(host.AgentVersion) { - legacy++ - } - } - details := []string{fmt.Sprintf("%s currently registered.", countWithNoun(len(hosts), "agent", "agents"))} if active > 0 { details = append(details, fmt.Sprintf("%s have a recent heartbeat.", countWithNoun(active, "agent", "agents"))) @@ -201,6 +196,30 @@ func buildAgentContinuityReadinessCheck(hosts []models.Host, targetVersion strin } } +func buildAgentMigrationSecurityReadinessCheck(hosts []models.Host) updates.UpdateReadinessCheck { + legacy := countLegacyAgents(hosts) + if legacy == 0 { + return updates.UpdateReadinessCheck{ + ID: "agent-migration-security", + Status: updateReadinessCheckPass, + Title: "Agent migration security", + Summary: "No v5 or legacy agents need a first-hop automatic migration.", + } + } + + return updates.UpdateReadinessCheck{ + ID: "agent-migration-security", + Status: updateReadinessCheckWarning, + Title: "Agent migration security", + Summary: "v5 agents can auto-update to v6, but the first hop depends on trusted transport.", + Details: []string{ + fmt.Sprintf("%s will use the v5 updater before v6 signature and self-test protections are available.", countWithNoun(legacy, "v5 or legacy agent", "v5 or legacy agents")), + "Use HTTPS, or keep the Pulse-to-agent migration path on a trusted local network; v5 checksum validation alone does not protect plain HTTP from an on-path attacker.", + "For high-assurance environments, reinstall the v6 pulse-agent through the signed installer path instead of relying on automatic first-hop migration.", + }, + } +} + func buildAgentTokenReadinessCheck(cfg *config.Config, agentCount int, now time.Time) updates.UpdateReadinessCheck { if cfg == nil { return updates.UpdateReadinessCheck{ @@ -277,6 +296,16 @@ func buildAgentTokenReadinessCheck(cfg *config.Config, agentCount int, now time. } } +func countLegacyAgents(hosts []models.Host) int { + legacy := 0 + for _, host := range hosts { + if host.IsLegacy || looksLikePreV6Version(host.AgentVersion) { + legacy++ + } + } + return legacy +} + func looksLikePreV6Version(version string) bool { version = strings.TrimSpace(strings.TrimPrefix(version, "v")) if version == "" { diff --git a/internal/api/update_readiness_test.go b/internal/api/update_readiness_test.go index 90b81c87a..f3d5653f0 100644 --- a/internal/api/update_readiness_test.go +++ b/internal/api/update_readiness_test.go @@ -9,7 +9,7 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/updates" ) -func TestBuildUpdateReadiness_ActiveV5AgentIsReady(t *testing.T) { +func TestBuildUpdateReadiness_ActiveV5AgentWarnsForFirstHopTransport(t *testing.T) { now := time.Date(2026, 5, 28, 12, 0, 0, 0, time.UTC) record, err := config.NewAPITokenRecord("abcdef1234567890abcdef1234567890", "agent", []string{config.ScopeAgentReport}) if err != nil { @@ -33,13 +33,21 @@ func TestBuildUpdateReadiness_ActiveV5AgentIsReady(t *testing.T) { now: now, }) - if readiness.Status != updateReadinessReady { - t.Fatalf("readiness status = %q, want %q: %#v", readiness.Status, updateReadinessReady, readiness) + if readiness.Status != updateReadinessAttention { + t.Fatalf("readiness status = %q, want %q: %#v", readiness.Status, updateReadinessAttention, readiness) } - for _, check := range readiness.Checks { - if check.Status != updateReadinessCheckPass { - t.Fatalf("check %s status = %q, want pass", check.ID, check.Status) - } + if got := readiness.Checks[2].ID; got != "agent-migration-security" { + t.Fatalf("check[2] id = %q, want agent-migration-security", got) + } + if got := readiness.Checks[2].Status; got != updateReadinessCheckWarning { + t.Fatalf("migration security status = %q, want warning", got) + } + if got := readiness.Checks[2].Summary; got != "v5 agents can auto-update to v6, but the first hop depends on trusted transport." { + t.Fatalf("migration security summary = %q", got) + } + wantDetail := "Use HTTPS, or keep the Pulse-to-agent migration path on a trusted local network; v5 checksum validation alone does not protect plain HTTP from an on-path attacker." + if !containsExactString(readiness.Checks[2].Details, wantDetail) { + t.Fatalf("migration security details missing %q: %#v", wantDetail, readiness.Checks[2].Details) } } @@ -69,11 +77,20 @@ func TestBuildUpdateReadiness_BlocksWhenAgentsHaveNoReportingToken(t *testing.T) if readiness.Status != updateReadinessBlocked { t.Fatalf("readiness status = %q, want %q: %#v", readiness.Status, updateReadinessBlocked, readiness) } - if got := readiness.Checks[2].Status; got != updateReadinessCheckBlocked { + if got := readiness.Checks[3].Status; got != updateReadinessCheckBlocked { t.Fatalf("agent token check status = %q, want blocked", got) } } +func containsExactString(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} + func TestBuildUpdateReadiness_WarnsOnStaleAgent(t *testing.T) { now := time.Date(2026, 5, 28, 12, 0, 0, 0, time.UTC) record, err := config.NewAPITokenRecord("abcdef1234567890abcdef1234567890", "agent", []string{config.ScopeAgentReport}) diff --git a/internal/api/updates_test.go b/internal/api/updates_test.go index ca3ead4c0..8813e5220 100644 --- a/internal/api/updates_test.go +++ b/internal/api/updates_test.go @@ -693,11 +693,17 @@ func TestHandleGetUpdatePlan_IncludesUpgradeReadiness(t *testing.T) { if plan.Readiness == nil { t.Fatal("expected readiness on update plan") } - if plan.Readiness.Status != "ready" { - t.Fatalf("readiness status = %q, want ready: %#v", plan.Readiness.Status, plan.Readiness) + if plan.Readiness.Status != "attention" { + t.Fatalf("readiness status = %q, want attention: %#v", plan.Readiness.Status, plan.Readiness) } - if len(plan.Readiness.Checks) != 3 { - t.Fatalf("readiness checks = %d, want 3", len(plan.Readiness.Checks)) + if len(plan.Readiness.Checks) != 4 { + t.Fatalf("readiness checks = %d, want 4", len(plan.Readiness.Checks)) + } + if got := plan.Readiness.Checks[2].ID; got != "agent-migration-security" { + t.Fatalf("readiness check[2] id = %q, want agent-migration-security", got) + } + if got := plan.Readiness.Checks[2].Status; got != "warning" { + t.Fatalf("agent migration security status = %q, want warning", got) } }