Guard infrastructure import plan approvals

- add candidate import approval and preview gate for node onboarding
- document metrics, availability, and proxy role behavior
- lock config auth reads and preserve storage metrics fallback
This commit is contained in:
rcourtman
2026-07-02 21:59:26 +01:00
parent aa1913d0bb
commit 97b9369333
26 changed files with 1013 additions and 54 deletions
+7 -7
View File
@@ -825,16 +825,16 @@ func checkAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request, write
}
return false
}
// Check username
userMatch := constantTimeStringEqual(parts[0], cfg.AuthUser)
// Check password - support both hashed and plain text for migration
// Config always has hashed password now (auto-hashed on load)
passMatch := internalauth.CheckPasswordHash(parts[1], cfg.AuthPass)
config.Mu.RLock()
cfgAuthUser := cfg.AuthUser
cfgAuthPass := cfg.AuthPass
config.Mu.RUnlock()
userMatch := constantTimeStringEqual(parts[0], cfgAuthUser)
passMatch := internalauth.CheckPasswordHash(parts[1], cfgAuthPass)
log.Debug().
Str("provided_user", parts[0]).
Str("expected_user", cfg.AuthUser).
Str("expected_user", cfgAuthUser).
Bool("user_match", userMatch).
Bool("pass_match", passMatch).
Msg("Auth check")
+33
View File
@@ -19622,3 +19622,36 @@ func TestContract_ProxyAuthConfiguredRoleHeaderMissingIsNonAdmin(t *testing.T) {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden)
}
}
// TestContract_LocalAuthConfigReadsUseSharedLock pins the shared mutable-config
// boundary for local username/password checks. Auth handlers may snapshot
// credentials under the config lock, but must not read mutable auth fields
// directly while hashing passwords or mutating sessions.
func TestContract_LocalAuthConfigReadsUseSharedLock(t *testing.T) {
expectations := map[string][]string{
"auth.go": {
"config.Mu.RLock()\n\t\t\t\t\t\tcfgAuthUser := cfg.AuthUser\n\t\t\t\t\t\tcfgAuthPass := cfg.AuthPass\n\t\t\t\t\t\tconfig.Mu.RUnlock()",
"constantTimeStringEqual(parts[0], cfgAuthUser)",
"internalauth.CheckPasswordHash(parts[1], cfgAuthPass)",
},
"router.go": {
"config.Mu.RLock()\n\t\tauthConfigured := (r.config.AuthUser != \"\" && r.config.AuthPass != \"\")",
"r.config.ProxyAuthSecret != \"\"\n\t\tconfig.Mu.RUnlock()",
"cfgAuthUser := r.config.AuthUser\n\tcfgAuthPass := r.config.AuthPass",
"constantTimeStringEqual(loginReq.Username, cfgAuthUser) && auth.CheckPasswordHash(loginReq.Password, cfgAuthPass)",
},
}
for file, snippets := range expectations {
sourceBytes, err := os.ReadFile(file)
if err != nil {
t.Fatalf("read %s: %v", file, err)
}
source := string(sourceBytes)
for _, snippet := range snippets {
if !strings.Contains(source, snippet) {
t.Fatalf("%s must contain %q", file, snippet)
}
}
}
}
+14 -6
View File
@@ -4098,13 +4098,17 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
skipCSRF := false
// Quick setup can run before auth exists. Keep bootstrap/recovery flows usable
// without a prior session+CSRF pair, but enforce CSRF once auth is configured.
config.Mu.RLock()
authConfigured := (r.config.AuthUser != "" && r.config.AuthPass != "") ||
r.config.HasAPITokens() ||
r.config.ProxyAuthSecret != "" ||
(func() bool {
ssoCfg := r.ensureSSOConfig()
return ssoCfg != nil && ssoCfg.HasEnabledProviders()
})()
r.config.ProxyAuthSecret != ""
config.Mu.RUnlock()
if !authConfigured {
ssoCfg := r.ensureSSOConfig()
if ssoCfg != nil && ssoCfg.HasEnabledProviders() {
authConfigured = true
}
}
validRecoveryToken := false
if recoveryToken := strings.TrimSpace(req.Header.Get("X-Recovery-Token")); recoveryToken != "" {
validRecoveryToken = GetRecoveryTokenStore().IsRecoveryTokenValidConstantTime(recoveryToken, clientIP)
@@ -5103,7 +5107,11 @@ func (r *Router) handleLogin(w http.ResponseWriter, req *http.Request) {
}
// Verify credentials (or accept any credentials when admin bypass is enabled)
credentialsValid := constantTimeStringEqual(loginReq.Username, r.config.AuthUser) && auth.CheckPasswordHash(loginReq.Password, r.config.AuthPass)
config.Mu.RLock()
cfgAuthUser := r.config.AuthUser
cfgAuthPass := r.config.AuthPass
config.Mu.RUnlock()
credentialsValid := constantTimeStringEqual(loginReq.Username, cfgAuthUser) && auth.CheckPasswordHash(loginReq.Password, cfgAuthPass)
effectiveUsername := loginReq.Username
if adminBypassEnabled() {
credentialsValid = true
@@ -2232,6 +2232,24 @@ func TestReloadAndRuntimeContextStayOnCanonicalMonitoringPath(t *testing.T) {
}
}
func TestUnifiedStorageMetricsFallbackToResourceIDWhenResolverTargetMissing(t *testing.T) {
data, err := os.ReadFile("monitor.go")
if err != nil {
t.Fatalf("failed to read monitor.go: %v", err)
}
source := string(data)
for _, snippet := range []string{
"target := resolver.MetricsTargetForResource(resource.ID)",
"if target != nil && target.ResourceType == \"storage\" && strings.TrimSpace(target.ResourceID) != \"\" {",
"targetID = strings.TrimSpace(target.ResourceID)",
"} else {\n\t\t\ttargetID = resource.ID\n\t\t}",
} {
if !strings.Contains(source, snippet) {
t.Fatalf("monitor.go must contain %q", snippet)
}
}
}
// TestPollersHonorDisabledInstanceFlag asserts that the PVE, PBS, and PMG
// pollers skip instances whose `Disabled` flag is set, both at client
// initialization/reconnect time and on every per-instance iteration of the
+5 -3
View File
@@ -4813,10 +4813,12 @@ func (m *Monitor) syncUnifiedStorageMetrics(store ResourceStoreInterface) {
}
target := resolver.MetricsTargetForResource(resource.ID)
if target == nil || target.ResourceType != "storage" || strings.TrimSpace(target.ResourceID) == "" {
continue
var targetID string
if target != nil && target.ResourceType == "storage" && strings.TrimSpace(target.ResourceID) != "" {
targetID = strings.TrimSpace(target.ResourceID)
} else {
targetID = resource.ID
}
targetID := strings.TrimSpace(target.ResourceID)
if _, ok := seenTargets[targetID]; ok {
continue
}