mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-03 04:38:48 +00:00
Merge pull request #2144 from rcourtman/maintainer/20260921T112617Z
Some checks are pending
Build and Test / Backend tests (rest-1) (push) Blocked by required conditions
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Detect changed areas (push) Waiting to run
Build and Test / Frontend (push) Blocked by required conditions
Build and Test / Backend tests (api) (push) Blocked by required conditions
Build and Test / Backend tests (rest-0) (push) Blocked by required conditions
Build and Test / Script smoke tests & backend build (push) Blocked by required conditions
Build and Test / Benchmarks (push) Blocked by required conditions
Canonical Governance / governance (push) Waiting to run
Canonical Private Governance / private-governance (push) Waiting to run
Patrol Qualification Regression / Catalog, scorer, and replay regression (push) Waiting to run
Public docs / check (push) Waiting to run
Core E2E Tests / Validate E2E tier selection (push) Waiting to run
Core E2E Tests / Offline Organization provisioning (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 1/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 2/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 3/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 4/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 5/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 6/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 7/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 8/8) (push) Blocked by required conditions
Core E2E Tests / Agent registration lifecycle (push) Waiting to run
Core E2E Tests / E2E verdict (push) Blocked by required conditions
Unified Agent Native Verification / Linux ARM64 (push) Waiting to run
Unified Agent Native Verification / Linux x64 (push) Waiting to run
Unified Agent Native Verification / Windows x64 (push) Waiting to run
Unified Agent Native Verification / macOS ARM64 (push) Waiting to run
Unified Agent Native Verification / macOS Intel (push) Waiting to run
Unified Agent Native Verification / FreeBSD cross-build contract (push) Waiting to run
Some checks are pending
Build and Test / Backend tests (rest-1) (push) Blocked by required conditions
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Detect changed areas (push) Waiting to run
Build and Test / Frontend (push) Blocked by required conditions
Build and Test / Backend tests (api) (push) Blocked by required conditions
Build and Test / Backend tests (rest-0) (push) Blocked by required conditions
Build and Test / Script smoke tests & backend build (push) Blocked by required conditions
Build and Test / Benchmarks (push) Blocked by required conditions
Canonical Governance / governance (push) Waiting to run
Canonical Private Governance / private-governance (push) Waiting to run
Patrol Qualification Regression / Catalog, scorer, and replay regression (push) Waiting to run
Public docs / check (push) Waiting to run
Core E2E Tests / Validate E2E tier selection (push) Waiting to run
Core E2E Tests / Offline Organization provisioning (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 1/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 2/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 3/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 4/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 5/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 6/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 7/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 8/8) (push) Blocked by required conditions
Core E2E Tests / Agent registration lifecycle (push) Waiting to run
Core E2E Tests / E2E verdict (push) Blocked by required conditions
Unified Agent Native Verification / Linux ARM64 (push) Waiting to run
Unified Agent Native Verification / Linux x64 (push) Waiting to run
Unified Agent Native Verification / Windows x64 (push) Waiting to run
Unified Agent Native Verification / macOS ARM64 (push) Waiting to run
Unified Agent Native Verification / macOS Intel (push) Waiting to run
Unified Agent Native Verification / FreeBSD cross-build contract (push) Waiting to run
Fix node Verify SSL setting reverting after re-registration (#2140)
This commit is contained in:
commit
eb47a4e653
15 changed files with 465 additions and 3 deletions
|
|
@ -3395,6 +3395,38 @@ Agent` secondary handoff against the live setup wizard instead of relying
|
|||
|
||||
## Current State
|
||||
|
||||
### Auto-register preserves the operator's stored TLS choice
|
||||
|
||||
Re-registering an existing Proxmox node through the canonical auto-register
|
||||
path preserves the stored `VerifySSL` value instead of replacing it with the
|
||||
fingerprint-capture result. This keeps a disabled "Verify SSL certificate"
|
||||
setting disabled across an agent health-check re-registration after a
|
||||
disconnect, while the legacy heal (`VerifySSL` true with no stored fingerprint)
|
||||
still downgrades to an insecure, parseable-certificate connection. The
|
||||
fingerprint pin may still refresh from the registration. New nodes continue to
|
||||
take the captured value. Regression tests
|
||||
`TestHandleCanonicalAutoRegister_PVEPreservesDisabledVerifySSL` and
|
||||
`TestHandleCanonicalAutoRegister_PBSReservesDisabledVerifySSL` pin the existing
|
||||
node branch.
|
||||
|
||||
`ConsolidatePVEInstances` respects an operator's explicit `VerifySSL` choice
|
||||
when it folds a duplicate cluster or an overlapping standalone into the
|
||||
canonical instance. An explicit choice is recorded as `VerifySSLExplicit` when
|
||||
the update or add handler applies a caller-supplied `verifySSL`, and a merge
|
||||
never overrides it, so a disabled setting is not silently re-enabled on every
|
||||
save, load and monitor reconciliation. When neither side recorded an explicit
|
||||
choice the historical promotion is kept, so an existing secure connection is
|
||||
not silently downgraded. Certificate pinning is carried separately by the
|
||||
merged `Fingerprint`, so a pin still verifies the peer. Regression tests
|
||||
`TestConsolidatePVEInstancesPreservesDisabledVerifySSLOnStandaloneMerge`,
|
||||
`TestConsolidatePVEInstancesPreservesDisabledVerifySSLOnDuplicateClusterMerge`
|
||||
and `TestHandleUpdateNodePreservesDisabledVerifySSLThroughConsolidation` cover
|
||||
the merge and save paths.
|
||||
|
||||
The persisted `VerifySSLExplicit` marker is internal node configuration. It is
|
||||
not added to the auto-register request or response, nor to the node API
|
||||
response, so existing clients see no payload change.
|
||||
|
||||
### Manual update freshness
|
||||
|
||||
Server update-check freshness is owned by internal/updates and its API adapter.
|
||||
|
|
|
|||
|
|
@ -4541,6 +4541,22 @@ auto-register mutation boundary.
|
|||
|
||||
## Current State
|
||||
|
||||
### Node auto-register TLS fields are operator-owned on update
|
||||
|
||||
The `/api/auto-register` completion request and response shapes are unchanged.
|
||||
When a registration matches an existing node, the captured fingerprint may
|
||||
still refresh the stored pin, but the stored `verifySSL` preference is not
|
||||
replaced by the registration's capture state. New nodes continue to take the
|
||||
captured value. This is a behavioural clarification of the existing endpoint,
|
||||
not a new field or contract delta.
|
||||
|
||||
The node add and update payloads keep `verifySSL` as an optional pointer, so an
|
||||
explicit `false` remains distinguishable from an omitted field. An explicit
|
||||
choice is recorded as operator-owned and is not overridden by automatic PVE
|
||||
consolidation, which runs on save, load and monitor reconciliation. The response
|
||||
shape is unchanged: the persisted explicit marker is internal and is not exposed
|
||||
through the node API.
|
||||
|
||||
### Manual update freshness
|
||||
|
||||
GET /api/updates/check accepts an optional boolean force query alongside the
|
||||
|
|
|
|||
|
|
@ -951,6 +951,20 @@ tokens, and path-normalization variants.
|
|||
|
||||
## Current State
|
||||
|
||||
### Node TLS verification preference is operator-owned
|
||||
|
||||
A PVE connection's `VerifySSL` value is security-relevant, so the stored
|
||||
operator choice must not be changed by automatic reconciliation. The persisted
|
||||
`VerifySSLExplicit` field records that a caller supplied `verifySSL` through the
|
||||
node add or update API, distinguishing a deliberate opt-out from the zero
|
||||
value. Automatic PVE consolidation (`ConsolidatePVEInstances`) and canonical
|
||||
auto-registration never override an explicit choice; the historical promotion
|
||||
is retained only when neither side recorded one, and a merged certificate
|
||||
fingerprint still pins the peer. A record written before the field existed
|
||||
loads as non-explicit and is unchanged until the operator saves a choice.
|
||||
`TestPVEInstanceVerifySSLExplicitRoundTrips` in
|
||||
`internal/config/config_load_test.go` pins the persisted default and round-trip.
|
||||
|
||||
### Manual update availability feedback
|
||||
|
||||
The settings state owner has a dedicated proof route to
|
||||
|
|
|
|||
|
|
@ -2611,6 +2611,19 @@ vdev layout is reported` in
|
|||
|
||||
## Current State
|
||||
|
||||
### TLS verification preference survives node re-registration
|
||||
|
||||
A PVE or PBS node whose operator disabled certificate verification keeps that
|
||||
preference when the host agent re-registers the node after a disconnect,
|
||||
including when the stored fingerprint was cleared. This prevents a rotating or
|
||||
self-signed certificate from silently re-enabling verification and breaking the
|
||||
monitoring path used for backup and recovery evidence. New nodes still take the
|
||||
captured registration value. The same preference is preserved when automatic
|
||||
PVE consolidation folds a duplicate cluster or an overlapping standalone into
|
||||
the canonical connection on save, load or monitor reconciliation; a merged
|
||||
certificate fingerprint still pins the peer. See the agent-lifecycle contract
|
||||
for the runtime mechanism.
|
||||
|
||||
### Manual update freshness
|
||||
|
||||
Server update-check freshness changes observation only. A refreshed availability
|
||||
|
|
|
|||
|
|
@ -1557,3 +1557,102 @@ func TestHandleCanonicalAutoRegister_PBSKeepsDistinctSameNameTokenWhenFingerprin
|
|||
t.Fatalf("new site name = %q, want disambiguation from existing %q", registered.Name, existing.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleCanonicalAutoRegister_PVEPreservesDisabledVerifySSL covers #2140:
|
||||
// an operator who disables "Verify SSL certificate" on an existing node must
|
||||
// not have it silently re-enabled by an agent health-check re-registration.
|
||||
func TestHandleCanonicalAutoRegister_PVEPreservesDisabledVerifySSL(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("PULSE_DATA_DIR", tempDir)
|
||||
|
||||
server := newIPv4TLSServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tokenID := "pulse-monitor@pve!" + buildPulseMonitorTokenName("pulse.example.com")
|
||||
cfg := &config.Config{
|
||||
DataPath: tempDir,
|
||||
ConfigPath: tempDir,
|
||||
PVEInstances: []config.PVEInstance{
|
||||
{
|
||||
Name: "pve01",
|
||||
Host: server.URL,
|
||||
TokenName: tokenID,
|
||||
TokenValue: "existing-token",
|
||||
VerifySSL: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
handler := newTestConfigHandlers(t, cfg)
|
||||
|
||||
reqBody := AutoRegisterRequest{
|
||||
Type: "pve",
|
||||
Host: server.URL,
|
||||
ServerName: "pve01",
|
||||
TokenID: tokenID,
|
||||
TokenValue: "rotated-token",
|
||||
Source: "agent",
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auto-register", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.handleCanonicalAutoRegister(rec, req, &reqBody, "127.0.0.1")
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
instance := handler.defaultConfig.PVEInstances[0]
|
||||
if instance.VerifySSL {
|
||||
t.Fatalf("re-registration re-enabled VerifySSL on an existing node that disabled it (#2140)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleCanonicalAutoRegister_PBSReservesDisabledVerifySSL is the PBS twin
|
||||
// of TestHandleCanonicalAutoRegister_PVEPreservesDisabledVerifySSL.
|
||||
func TestHandleCanonicalAutoRegister_PBSReservesDisabledVerifySSL(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("PULSE_DATA_DIR", tempDir)
|
||||
|
||||
server := newIPv4TLSServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tokenID := "pulse-monitor@pbs!" + buildPulseMonitorTokenName("pulse.example.com")
|
||||
cfg := &config.Config{
|
||||
DataPath: tempDir,
|
||||
ConfigPath: tempDir,
|
||||
PBSInstances: []config.PBSInstance{
|
||||
{
|
||||
Name: "pbs01",
|
||||
Host: server.URL,
|
||||
TokenName: tokenID,
|
||||
TokenValue: "existing-token",
|
||||
VerifySSL: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
handler := newTestConfigHandlers(t, cfg)
|
||||
|
||||
reqBody := AutoRegisterRequest{
|
||||
Type: "pbs",
|
||||
Host: server.URL,
|
||||
ServerName: "pbs01",
|
||||
TokenID: tokenID,
|
||||
TokenValue: "rotated-token",
|
||||
Source: "agent",
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auto-register", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.handleCanonicalAutoRegister(rec, req, &reqBody, "127.0.0.1")
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
instance := handler.defaultConfig.PBSInstances[0]
|
||||
if instance.VerifySSL {
|
||||
t.Fatalf("re-registration re-enabled VerifySSL on an existing node that disabled it (#2140)")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -541,3 +541,60 @@ func TestHandleUpdateNode_UserOnlyEditDoesNotClearProxmoxTokenAuth(t *testing.T)
|
|||
t.Fatalf("PMG monitor mail stats should preserve false when omitted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleUpdateNodePreservesDisabledVerifySSLThroughConsolidation covers
|
||||
// #2140 end to end through the reported save path: an operator disables
|
||||
// "Verify SSL certificate" on a cluster that also has an overlapping
|
||||
// auto-registered standalone entry, saves, and reopens Manage. The save runs
|
||||
// normalizePVEConfigState, which folds the standalone into the cluster; the
|
||||
// disabled setting must survive that consolidation.
|
||||
func TestHandleUpdateNodePreservesDisabledVerifySSLThroughConsolidation(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
cfg := &config.Config{
|
||||
DataPath: tempDir,
|
||||
PVEInstances: []config.PVEInstance{
|
||||
{
|
||||
Name: "homelab",
|
||||
ClusterName: "cluster-A",
|
||||
IsCluster: true,
|
||||
VerifySSL: true,
|
||||
ClusterEndpoints: []config.ClusterEndpoint{
|
||||
{NodeName: "minipc", Host: "https://10.0.0.5:8006"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "minipc-standalone",
|
||||
Host: "10.0.0.5",
|
||||
Fingerprint: "fp-standalone",
|
||||
TokenName: "pulse@pve!token",
|
||||
TokenValue: "secret",
|
||||
VerifySSL: true,
|
||||
Source: "agent",
|
||||
},
|
||||
},
|
||||
}
|
||||
handler := newTestConfigHandlers(t, cfg)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{"verifySSL": false})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/config/nodes/pve-0", bytes.NewBuffer(body))
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleUpdateNode(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("update status = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(cfg.PVEInstances) != 1 {
|
||||
t.Fatalf("instances = %d, want 1 after consolidation", len(cfg.PVEInstances))
|
||||
}
|
||||
if cfg.PVEInstances[0].VerifySSL {
|
||||
t.Fatalf("save re-enabled the disabled VerifySSL through consolidation (#2140)")
|
||||
}
|
||||
|
||||
nodes := handler.GetAllNodesForAPI(req.Context())
|
||||
if len(nodes) != 1 {
|
||||
t.Fatalf("nodes = %d, want 1", len(nodes))
|
||||
}
|
||||
if nodes[0].VerifySSL {
|
||||
t.Fatalf("GET projection VerifySSL = true, want false after disabling (#2140)")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -516,6 +516,7 @@ func (h *ConfigHandlers) handleAddNode(w http.ResponseWriter, r *http.Request) {
|
|||
TokenValue: req.TokenValue,
|
||||
Fingerprint: req.Fingerprint,
|
||||
VerifySSL: verifySSL,
|
||||
VerifySSLExplicit: req.VerifySSL != nil,
|
||||
MonitorVMs: monitorVMs,
|
||||
MonitorContainers: monitorContainers,
|
||||
MonitorStorage: monitorStorage,
|
||||
|
|
@ -1493,6 +1494,7 @@ func (h *ConfigHandlers) handleUpdateNode(w http.ResponseWriter, r *http.Request
|
|||
}
|
||||
if req.VerifySSL != nil {
|
||||
updated.VerifySSL = *req.VerifySSL
|
||||
updated.VerifySSLExplicit = true
|
||||
}
|
||||
if req.MonitorVMs != nil {
|
||||
updated.MonitorVMs = *req.MonitorVMs
|
||||
|
|
|
|||
|
|
@ -2265,7 +2265,17 @@ func (h *ConfigHandlers) handleCanonicalAutoRegister(w http.ResponseWriter, r *h
|
|||
if pveNode.Fingerprint != "" {
|
||||
instance.Fingerprint = pveNode.Fingerprint
|
||||
}
|
||||
instance.VerifySSL = pveNode.VerifySSL
|
||||
// Preserve the operator's TLS choice on an existing connection.
|
||||
// The incoming value records only whether this registration
|
||||
// captured a fingerprint; applying it unconditionally re-enabled a
|
||||
// disabled "Verify SSL certificate" setting on every agent
|
||||
// health-check re-registration (#2140). Heal only the legacy state
|
||||
// where strict verification is on with no pin and no recorded
|
||||
// operator choice, which can never connect to a self-signed
|
||||
// endpoint (#1303).
|
||||
if !instance.VerifySSLExplicit && instance.VerifySSL && instance.Fingerprint == "" {
|
||||
instance.VerifySSL = false
|
||||
}
|
||||
log.Info().Str("host", host).Str("type", "pve").Msg(canonicalAutoRegisterMatchMessage("host; updated token in-place"))
|
||||
} else if h.adoptCanonicalAutoRegisterClusterMember(r.Context(), serverName, host, fingerprint, fullTokenID, tokenValue, candidateHosts, registrationSource) {
|
||||
// A non-primary cluster member: the cluster connection already
|
||||
|
|
@ -2319,7 +2329,16 @@ func (h *ConfigHandlers) handleCanonicalAutoRegister(w http.ResponseWriter, r *h
|
|||
if pbsNode.Fingerprint != "" {
|
||||
instance.Fingerprint = pbsNode.Fingerprint
|
||||
}
|
||||
instance.VerifySSL = pbsNode.VerifySSL
|
||||
// Preserve the operator's TLS choice on an existing connection.
|
||||
// The incoming value records only whether this registration
|
||||
// captured a fingerprint; applying it unconditionally re-enabled a
|
||||
// disabled "Verify SSL certificate" setting on every agent
|
||||
// health-check re-registration (#2140). Heal only the legacy state
|
||||
// where strict verification is on with no pin, which can never
|
||||
// connect to a self-signed endpoint (#1303).
|
||||
if instance.VerifySSL && instance.Fingerprint == "" {
|
||||
instance.VerifySSL = false
|
||||
}
|
||||
log.Info().Str("host", host).Str("type", "pbs").Msg(canonicalAutoRegisterMatchMessage("host; updated token in-place"))
|
||||
} else {
|
||||
// Agent-token auth is restricted to updating existing nodes only.
|
||||
|
|
|
|||
49
internal/api/configapi/zz_verify_ssl_repro_test.go
Normal file
49
internal/api/configapi/zz_verify_ssl_repro_test.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
package configapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
)
|
||||
|
||||
// Reproduction probe for issue #2140: a PVE node's "Verify SSL certificate"
|
||||
// toggle must survive a PUT that disables it, and the GET projection must
|
||||
// report the disabled value back to the form.
|
||||
func TestRepro2140VerifySSLPersistsOnUpdate(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
DataPath: t.TempDir(),
|
||||
PVEInstances: []config.PVEInstance{{
|
||||
Name: "node-a",
|
||||
Host: "https://pve.local:8006",
|
||||
TokenName: "pulse-monitor@pve!pulse",
|
||||
TokenValue: "secret",
|
||||
Fingerprint: "AA:BB:CC",
|
||||
VerifySSL: true,
|
||||
}},
|
||||
}
|
||||
handler := newTestConfigHandlers(t, cfg)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{"verifySSL": false})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/config/nodes/pve-0", bytes.NewBuffer(body))
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleUpdateNode(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("update status = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if cfg.PVEInstances[0].VerifySSL {
|
||||
t.Fatalf("stored VerifySSL = true, want false after disabling")
|
||||
}
|
||||
|
||||
nodes := handler.GetAllNodesForAPI(req.Context())
|
||||
if len(nodes) != 1 {
|
||||
t.Fatalf("nodes = %d, want 1", len(nodes))
|
||||
}
|
||||
if nodes[0].VerifySSL {
|
||||
t.Fatalf("GET projection VerifySSL = true, want false")
|
||||
}
|
||||
}
|
||||
|
|
@ -1616,6 +1616,32 @@ func TestContract_NodeConfigUpdateTracksOptionalConnectionFieldsAndRedactedSecre
|
|||
}
|
||||
}
|
||||
|
||||
// TestContract_NodeConfigUpdateVerifySSLIsOptionalExplicitField pins the node
|
||||
// config update payload contract the TLS-preference fix relies on: verifySSL is
|
||||
// an optional pointer, so an explicit false is distinguishable from an omitted
|
||||
// value. The update handler records an explicit choice from the non-nil pointer
|
||||
// and must never treat an omitted field as an opt-out (#2140).
|
||||
func TestContract_NodeConfigUpdateVerifySSLIsOptionalExplicitField(t *testing.T) {
|
||||
var disabled NodeConfigRequest
|
||||
if err := json.Unmarshal([]byte(`{"verifySSL":false}`), &disabled); err != nil {
|
||||
t.Fatalf("decode explicit disable request: %v", err)
|
||||
}
|
||||
if disabled.VerifySSL == nil {
|
||||
t.Fatal("explicit verifySSL:false must decode to a non-nil pointer")
|
||||
}
|
||||
if *disabled.VerifySSL {
|
||||
t.Fatal("explicit verifySSL:false must decode to false")
|
||||
}
|
||||
|
||||
var omitted NodeConfigRequest
|
||||
if err := json.Unmarshal([]byte(`{"name":"cluster"}`), &omitted); err != nil {
|
||||
t.Fatalf("decode request without verifySSL: %v", err)
|
||||
}
|
||||
if omitted.VerifySSL != nil {
|
||||
t.Fatal("omitted verifySSL must stay nil so an existing TLS choice is preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContract_HostedMagicLinkStablePrincipalProof(t *testing.T) {
|
||||
source, err := os.ReadFile(filepath.Clean("magic_link_handlers.go"))
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -570,6 +570,7 @@ type PVEInstance struct {
|
|||
TokenValue string
|
||||
Fingerprint string
|
||||
VerifySSL bool
|
||||
VerifySSLExplicit bool `json:"verifySSLExplicit,omitempty"` // operator-chosen VerifySSL; survives automatic consolidation (#2140)
|
||||
MonitorVMs bool
|
||||
MonitorContainers bool
|
||||
MonitorStorage bool
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package config
|
|||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
|
@ -663,3 +664,29 @@ func TestLoadSystemSettingsWithRetry(t *testing.T) {
|
|||
assert.Equal(t, 1, calls)
|
||||
})
|
||||
}
|
||||
|
||||
// TestPVEInstanceVerifySSLExplicitRoundTrips pins the persisted TLS-choice
|
||||
// field: an operator's explicit selection must survive serialization so
|
||||
// automatic consolidation can distinguish it from the zero value, while a
|
||||
// record written before the field existed loads as the non-explicit default
|
||||
// (#2140).
|
||||
func TestPVEInstanceVerifySSLExplicitRoundTrips(t *testing.T) {
|
||||
original := PVEInstance{
|
||||
Name: "homelab",
|
||||
Host: "https://pve.local:8006",
|
||||
VerifySSL: false,
|
||||
VerifySSLExplicit: true,
|
||||
}
|
||||
encoded, err := json.Marshal(original)
|
||||
require.NoError(t, err)
|
||||
|
||||
var decoded PVEInstance
|
||||
require.NoError(t, json.Unmarshal(encoded, &decoded))
|
||||
assert.False(t, decoded.VerifySSL)
|
||||
assert.True(t, decoded.VerifySSLExplicit, "an explicit TLS choice must survive persistence")
|
||||
|
||||
var legacy PVEInstance
|
||||
require.NoError(t, json.Unmarshal([]byte(`{"Name":"legacy","Host":"https://pve.local:8006"}`), &legacy))
|
||||
assert.False(t, legacy.VerifySSL)
|
||||
assert.False(t, legacy.VerifySSLExplicit, "a record without the field is not an explicit choice")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -318,7 +318,20 @@ func mergePVEInstanceData(dst *PVEInstance, src PVEInstance) {
|
|||
if dst.Source == "" && strings.TrimSpace(src.Source) != "" {
|
||||
dst.Source = strings.TrimSpace(src.Source)
|
||||
}
|
||||
if !dst.VerifySSL && src.VerifySSL {
|
||||
// TLS verification preference: an explicit operator choice wins over an
|
||||
// inferred one. When neither side recorded a choice, keep the historical
|
||||
// promotion of a strict source so an existing secure connection is not
|
||||
// silently downgraded. A disabled setting that the operator saved through
|
||||
// the API is explicit and is never re-enabled by a merge (#2140).
|
||||
// Certificate pinning is carried separately by the Fingerprint copy above,
|
||||
// so a merged pin still verifies the peer even when VerifySSL stays false.
|
||||
switch {
|
||||
case dst.VerifySSLExplicit:
|
||||
// The canonical instance's explicit choice is authoritative.
|
||||
case src.VerifySSLExplicit:
|
||||
dst.VerifySSL = src.VerifySSL
|
||||
dst.VerifySSLExplicit = true
|
||||
case !dst.VerifySSL && src.VerifySSL:
|
||||
dst.VerifySSL = true
|
||||
}
|
||||
if dst.TemperatureMonitoringEnabled == nil && src.TemperatureMonitoringEnabled != nil {
|
||||
|
|
|
|||
|
|
@ -286,3 +286,87 @@ func TestConsolidatePVEInstancesKeepsStandaloneWithContradictingFingerprint(t *t
|
|||
t.Fatalf("expected both instances to remain, got %d", len(instances))
|
||||
}
|
||||
}
|
||||
|
||||
// TestConsolidatePVEInstancesPreservesDisabledVerifySSLOnStandaloneMerge covers
|
||||
// #2140: a standalone/auto-registered entry that captured a fingerprint is
|
||||
// created with VerifySSL true. Folding it into a cluster whose operator
|
||||
// disabled "Verify SSL certificate" must not re-enable the setting. The
|
||||
// captured pin still moves onto the cluster endpoint, so verification is not
|
||||
// silently downgraded.
|
||||
func TestConsolidatePVEInstancesPreservesDisabledVerifySSLOnStandaloneMerge(t *testing.T) {
|
||||
instances, changed := ConsolidatePVEInstances([]PVEInstance{
|
||||
{
|
||||
Name: "homelab",
|
||||
ClusterName: "cluster-A",
|
||||
IsCluster: true,
|
||||
VerifySSL: false,
|
||||
VerifySSLExplicit: true,
|
||||
ClusterEndpoints: []ClusterEndpoint{
|
||||
{NodeName: "minipc", Host: "https://10.0.0.5:8006"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "minipc-standalone",
|
||||
Host: "10.0.0.5",
|
||||
Fingerprint: "fp-standalone",
|
||||
TokenName: "pulse@pve!token",
|
||||
TokenValue: "secret",
|
||||
VerifySSL: true,
|
||||
Source: "agent",
|
||||
},
|
||||
})
|
||||
|
||||
if !changed {
|
||||
t.Fatalf("expected consolidation change")
|
||||
}
|
||||
if len(instances) != 1 {
|
||||
t.Fatalf("expected 1 instance after consolidation, got %d", len(instances))
|
||||
}
|
||||
if instances[0].VerifySSL {
|
||||
t.Fatalf("standalone merge re-enabled a disabled VerifySSL (#2140)")
|
||||
}
|
||||
if got := instances[0].ClusterEndpoints[0].Fingerprint; got != "fp-standalone" {
|
||||
t.Fatalf("ClusterEndpoint Fingerprint = %q, want fp-standalone (pin must survive)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConsolidatePVEInstancesPreservesDisabledVerifySSLOnDuplicateClusterMerge
|
||||
// is the duplicate-cluster twin of the #2140 regression above.
|
||||
func TestConsolidatePVEInstancesPreservesDisabledVerifySSLOnDuplicateClusterMerge(t *testing.T) {
|
||||
instances, changed := ConsolidatePVEInstances([]PVEInstance{
|
||||
{
|
||||
Name: "c1",
|
||||
ClusterName: "cluster-A",
|
||||
IsCluster: true,
|
||||
VerifySSL: false,
|
||||
VerifySSLExplicit: true,
|
||||
ClusterEndpoints: []ClusterEndpoint{
|
||||
{NodeName: "n1", Host: "https://10.0.0.5:8006"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "c2",
|
||||
ClusterName: "cluster-A",
|
||||
IsCluster: true,
|
||||
VerifySSL: true,
|
||||
Fingerprint: "fp-1",
|
||||
ClusterEndpoints: []ClusterEndpoint{
|
||||
{NodeName: "n1", Host: "https://10.0.0.5:8006"},
|
||||
{NodeName: "n2", Host: "https://10.0.0.6:8006"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if !changed {
|
||||
t.Fatalf("expected consolidation change")
|
||||
}
|
||||
if len(instances) != 1 {
|
||||
t.Fatalf("expected 1 instance after consolidation, got %d", len(instances))
|
||||
}
|
||||
if instances[0].VerifySSL {
|
||||
t.Fatalf("duplicate-cluster merge re-enabled a disabled VerifySSL (#2140)")
|
||||
}
|
||||
if got := instances[0].Fingerprint; got != "fp-1" {
|
||||
t.Fatalf("Fingerprint = %q, want fp-1 (pin must survive)", got)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,6 +65,16 @@
|
|||
"incomplete_commit": "6f0dc177c8ab3cd4174e53d0774f6e278299a299",
|
||||
"completion_commit": "8738eb1bffbaab4c1d6cc3c23890dbbbc4888a69",
|
||||
"reason": "Evaluate the windowed item-height estimate repair together with the follow-up commit that records its frontend-primitives and performance-and-scalability contract sections, as one reviewed completion unit."
|
||||
},
|
||||
{
|
||||
"incomplete_commit": "529e8155253c4536696986e2993bb3b2f75eb71c",
|
||||
"completion_commit": "9fa81dee8fba760b980d821d6e10560589c110f2",
|
||||
"reason": "Evaluate the canonical auto-register VerifySSL preservation runtime change together with the follow-up commit that records its agent-lifecycle, api-contracts and dependent storage-recovery contract sections and the node API payload proof, as one reviewed completion unit."
|
||||
},
|
||||
{
|
||||
"incomplete_commit": "919009d7677318c071f888eb28c252d2d79d2728",
|
||||
"completion_commit": "9fa81dee8fba760b980d821d6e10560589c110f2",
|
||||
"reason": "Evaluate the PVE consolidation explicit-VerifySSL runtime change together with the follow-up commit that records its agent-lifecycle, api-contracts, security-privacy and dependent storage-recovery contract sections and the node API payload and persisted-field proofs, as one reviewed completion unit."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue