mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user