Surface v5 agent migration security guidance

Warn when update readiness includes v5 agents and document the trusted-transport first hop.
This commit is contained in:
rcourtman
2026-06-03 09:18:07 +01:00
parent 1afd708e5a
commit e17916e3bc
9 changed files with 140 additions and 37 deletions
+9
View File
@@ -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.
+8
View File
@@ -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
@@ -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
@@ -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
@@ -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
+18
View File
@@ -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)
}
}
}
+36 -7
View File
@@ -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 == "" {
+25 -8
View File
@@ -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})
+10 -4
View File
@@ -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)
}
}