diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 0eb1b51a8c..50505a27e0 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -69,6 +69,12 @@ criterion. It records elapsed time and both targets' request counts; unchanged pin, admission and routing assertions remain required. Runtime timeouts and separate timeout/cancellation controls are unchanged. This is not a diagnosis or dismissal of the retained earlier one-second timeout. +Lock-evidence fixture cases use independent base-path namespaces while each +case's clients share the same guard key. A recycled loopback port cannot inherit +an earlier synthetic guest's cooldown. `TestGuestAgentLockEvidenceFixtureIsolation` +shares one listener and retains an earlier uncertainty fence unchanged while an +independent fixture completes its lock/command checks. No runtime fence is +cleared, expired or weakened; native guest recovery is not established. This establishes source/consumer behaviour, not native recovery or attribution of a guest, PBS or certificate report. diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index 1e306edb29..e16167a0e7 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -70,6 +70,12 @@ criterion. It records elapsed time and both targets' request counts; unchanged pin, admission and routing assertions remain required. Runtime timeouts and separate timeout/cancellation controls are unchanged. This is not a diagnosis or dismissal of the retained earlier one-second timeout. +Lock-evidence fixture cases use independent base-path namespaces while each +case's clients share the same guard key. A recycled loopback port cannot inherit +an earlier synthetic guest's cooldown. `TestGuestAgentLockEvidenceFixtureIsolation` +shares one listener and retains an earlier uncertainty fence unchanged while an +independent fixture completes its lock/command checks. No runtime fence is +cleared, expired or weakened; native guest recovery is not established. This establishes source/consumer behaviour, not native recovery or attribution of a guest, PBS or certificate report. diff --git a/pkg/proxmox/guest_agent_lock_evidence_test.go b/pkg/proxmox/guest_agent_lock_evidence_test.go index 9bfb0ab157..61ecb9297f 100644 --- a/pkg/proxmox/guest_agent_lock_evidence_test.go +++ b/pkg/proxmox/guest_agent_lock_evidence_test.go @@ -54,7 +54,7 @@ func TestGuestAgentLockEvidenceRejectsAmbiguity(t *testing.T) { backupAgentPayload(w, r) })) defer server.Close() - c := backupTestClient(t, server.URL) + c := backupTestClient(t, lockEvidenceFixtureEndpoint(t, server.URL)) if err := read(context.Background(), c, 105); GuestAgentDeferredReason(err) != "lock-unverified" { t.Errorf("ambiguous lock admitted/published a command: %v", err) } @@ -68,7 +68,7 @@ func TestGuestAgentLockEvidenceRejectsAmbiguity(t *testing.T) { healthy.Store(true) // A completed command's failed lock check is not transport // uncertainty. A new diagnostic must reverify and resume. - diagnostic := backupTestClient(t, server.URL) + diagnostic := backupTestClient(t, lockEvidenceFixtureEndpoint(t, server.URL)) if err := read(context.Background(), diagnostic, 105); err != nil { t.Fatalf("complete unlocked config did not resume: %v", err) } @@ -116,7 +116,7 @@ func TestGuestAgentLockEvidenceRejectsRedirects(t *testing.T) { } })) defer server.Close() - c := backupTestClient(t, server.URL) + c := backupTestClient(t, lockEvidenceFixtureEndpoint(t, server.URL)) if err := read(context.Background(), c, 105); GuestAgentDeferredReason(err) != "lock-unverified" { t.Errorf("redirected lock admitted/published a command: %v", err) } @@ -145,7 +145,7 @@ func TestGuestAgentLockEvidenceRejectsRedirects(t *testing.T) { t.Errorf("ordinary redirected requests = %d, want %d", redirected.Load(), wantRedirected) } healthy.Store(true) - if err := read(context.Background(), backupTestClient(t, server.URL), 105); err != nil { + if err := read(context.Background(), backupTestClient(t, lockEvidenceFixtureEndpoint(t, server.URL)), 105); err != nil { t.Fatalf("valid same-target config did not resume: %v", err) } if commands.Load() != want+1 || redirected.Load() != wantRedirected { @@ -186,7 +186,7 @@ func TestGuestAgentLockEvidenceHealthyConfigAndSessionRecovery(t *testing.T) { } })) defer server.Close() - c, err := NewClient(ClientConfig{Host: server.URL, User: "fixture@pam", Password: "fixture-password", Timeout: time.Second}) + c, err := NewClient(ClientConfig{Host: lockEvidenceFixtureEndpoint(t, server.URL), User: "fixture@pam", Password: "fixture-password", Timeout: time.Second}) if err != nil { t.Fatal(err) } @@ -199,3 +199,52 @@ func TestGuestAgentLockEvidenceHealthyConfigAndSessionRecovery(t *testing.T) { }) } } + +// Shared guest fences intentionally outlive a closed fixture listener. A +// recycled loopback port must not make an independent lock case inherit an +// earlier case's VM-wide uncertainty. Every client within a case keeps its key. +func lockEvidenceFixtureEndpoint(t *testing.T, serverURL string) string { + t.Helper() + return serverURL + "/lock-evidence-fixture/" + strings.ReplaceAll(t.Name(), "/", "-") +} + +func TestGuestAgentLockEvidenceFixtureIsolation(t *testing.T) { + var configs, commands atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/config") { + configs.Add(1) + fmt.Fprint(w, `{"data":{}}`) + return + } + commands.Add(1) + backupAgentPayload(w, r) + })) + defer server.Close() + // Deliberately share the listener; no OS port-reuse timing is needed. + old := backupTestClient(t, server.URL+"/previous-lock-fixture") + release, err := acquireGuestAgent(old.baseURL, 105) + if err != nil { + t.Fatal(err) + } + release(true) + key, valid := guestAgentEndpointKey(old.baseURL) + if !valid { + t.Fatal("invalid old fixture key") + } + guestAgentGuards.Lock() + retained := guestAgentGuards.entries[guestAgentGuardKey{endpoint: key, vmid: 105}] + guestAgentGuards.Unlock() + current := backupTestClient(t, lockEvidenceFixtureEndpoint(t, server.URL)) + if _, err := current.GetVMAgentInfo(context.Background(), "node", 105); err != nil { + t.Fatalf("independent lock fixture inherited earlier uncertainty: %v", err) + } + if _, err := old.GetVMAgentInfo(context.Background(), "node", 105); GuestAgentDeferredReason(err) != "agent-cooldown" { + t.Fatalf("isolation weakened the original fixture fence: %v", err) + } + guestAgentGuards.Lock() + unchanged := guestAgentGuards.entries[guestAgentGuardKey{endpoint: key, vmid: 105}] == retained + guestAgentGuards.Unlock() + if !unchanged || commands.Load() != 1 || configs.Load() != 2 { + t.Fatal("isolation changed a retained fence or dispatched extra work") + } +}