diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 23a61ce22..a3903ee8a 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -15,6 +15,11 @@ ## Purpose +First-run security setup persists the canonical authentication environment file +before activating runtime credentials for every deployment shape. A systemd +override may additionally bind service startup, but agent and server lifecycle +must not depend on successful service-name detection to retain authentication. + API-token names are operator-facing metadata, not agent identity. Renaming an existing token must preserve its ID, secret hash, scopes, expiry, and connected agent sessions, so lifecycle continuity never depends on a display label. diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index a12427d5e..52cda5f0a 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -20,6 +20,11 @@ ## Purpose +Successful quick security setup is durable before its success response is +emitted: Pulse writes the canonical auth environment file, activates the same +hashed credentials in memory, and for root systemd installs reports both the +canonical `envFile` and the supplemental service override behavior. + `PATCH /api/security/tokens/{id}` supports bounded metadata updates. A nonblank `name` renames the record without rotating its secret or changing scopes; a `scopes` update retains the existing delegation checks, and omitted fields are diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 6208c1542..dbc0998c8 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -21,6 +21,11 @@ ## Purpose +First-run authentication always writes the canonical `.env` persistence +artifact before runtime state changes. Root systemd installation may also +write a service override, but reload, restart, backup, and recovery remain +anchored to the canonical data/config-path file rather than service discovery. + Token display-name changes use the existing API-token persistence boundary. The in-memory record must roll back if persistence fails, while the token ID, secret hash, scopes, and expiry remain intact so restart and restore preserve diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 0c9349569..e34209f8e 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -10590,6 +10590,54 @@ func TestContract_QuickSecuritySetupBootstrapRetrievalGuidance(t *testing.T) { } } +func TestContract_QuickSecuritySetupPersistsCanonicalAuthEnvironment(t *testing.T) { + resetPersistentAuthStoresForTests() + t.Cleanup(resetPersistentAuthStoresForTests) + + tempDir := t.TempDir() + cfg := &config.Config{ + DataPath: tempDir, + ConfigPath: tempDir, + } + router := &Router{ + config: cfg, + persistence: config.NewConfigPersistence(cfg.DataPath), + } + router.initializeBootstrapToken() + InitPersistentAuthStores(tempDir) + + token, _, _, err := loadOrCreateBootstrapToken(tempDir) + if err != nil { + t.Fatalf("loadOrCreateBootstrapToken: %v", err) + } + body := `{"username":"canonical-admin","password":"StrongPass!1","apiToken":"` + strings.Repeat("ac", 32) + `"}` + req := httptest.NewRequest(http.MethodPost, "/api/security/quick-setup", strings.NewReader(body)) + req.RemoteAddr = "127.0.0.1:54321" + req.Header.Set(bootstrapTokenHeader, token) + rec := httptest.NewRecorder() + + authLimiter.Reset("127.0.0.1") + handleQuickSecuritySetupFixed(router)(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("quick setup status = %d, want 200 (%s)", rec.Code, rec.Body.String()) + } + + authEnv, err := os.ReadFile(resolveAuthEnvPath(cfg.ConfigPath)) + if err != nil { + t.Fatalf("read canonical auth environment: %v", err) + } + authText := string(authEnv) + if !strings.Contains(authText, "PULSE_AUTH_USER='canonical-admin'\n") { + t.Fatalf("canonical auth environment missing username") + } + if !strings.Contains(authText, "PULSE_AUTH_PASS='$2") { + t.Fatalf("canonical auth environment missing password hash") + } + if strings.Contains(authText, "StrongPass!1") { + t.Fatal("canonical auth environment stored the plaintext password") + } +} + // A valid bootstrap token authorizes quick setup from any origin — the token // is the security boundary, and it's only readable by callers with filesystem // access to the Pulse data directory. The loopback-only path remains for the diff --git a/internal/api/security_setup_fix.go b/internal/api/security_setup_fix.go index 4737f56f2..1b26dc2f1 100644 --- a/internal/api/security_setup_fix.go +++ b/internal/api/security_setup_fix.go @@ -484,6 +484,17 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc { } setAPITokenOwnerUserID(tokenRecord, setupRequest.Username) + // Persist the canonical auth file for every deployment type before + // updating runtime state. Systemd root installs also write an override + // below, but the canonical file keeps config reloads and recovery + // independent of service-name detection. + envPath, err := writeAuthEnvFile(r.config.ConfigPath, r.config.DataPath, authEnvContent) + if err != nil { + log.Error().Err(err).Msg("Failed to write authentication configuration") + http.Error(w, "Failed to save security configuration", http.StatusInternalServerError) + return + } + if r.config.HasAPITokens() && r.config.AuthUser == "" && r.config.AuthPass == "" { // We had API-only access before, now replacing with full security log.Info().Msg("Replacing API-only token with new secure token") @@ -545,13 +556,6 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc { // Choose appropriate method based on environment if isDocker { - envPath, err := writeAuthEnvFile(r.config.ConfigPath, r.config.DataPath, authEnvContent) - if err != nil { - log.Error().Err(err).Msg("Failed to write .env file in Docker") - http.Error(w, "Failed to save security configuration", http.StatusInternalServerError) - return - } - log.Info().Str("path", envPath).Msg("Docker security configuration saved") response := map[string]interface{}{ @@ -571,13 +575,6 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc { // Systemd but not root (ProxmoxVE script scenario) // Don't attempt sudo, just save config and provide instructions - envPath, err := writeAuthEnvFile(r.config.ConfigPath, r.config.DataPath, authEnvContent) - if err != nil { - log.Error().Err(err).Msg("Failed to write .env file") - http.Error(w, "Failed to save security configuration", http.StatusInternalServerError) - return - } - // Create response - security is active immediately response := map[string]interface{}{ "success": true, @@ -629,11 +626,12 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc { "success": true, "method": "systemd-root", "serviceName": serviceName, + "envFile": envPath, "deploymentType": updates.GetDeploymentType(), "automatic": true, "requiresManualRestart": false, "message": "Security enabled immediately! Your settings are saved and active.", - "note": "Systemd override created for persistence across restarts.", + "note": "Authentication file and systemd override created for persistence across restarts.", } w.Header().Set("Content-Type", "application/json") @@ -642,12 +640,6 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc { } else { // Manual installation or development - envPath, err := writeAuthEnvFile(r.config.ConfigPath, r.config.DataPath, authEnvContent) - if err != nil { - log.Error().Err(err).Msg("Failed to write .env file") - // Still return success with manual instructions - } - // Get deployment type for restart instructions deploymentType := updates.GetDeploymentType() diff --git a/internal/api/security_setup_fix_test.go b/internal/api/security_setup_fix_test.go index c32fe659e..071c6d7d1 100644 --- a/internal/api/security_setup_fix_test.go +++ b/internal/api/security_setup_fix_test.go @@ -147,6 +147,18 @@ func TestQuickSecuritySetupRequiresBootstrapToken(t *testing.T) { if router.bootstrapTokenHash != "" { t.Fatalf("expected bootstrap token hash to be cleared after successful setup") } + + authEnv, err := os.ReadFile(resolveAuthEnvPath(cfg.ConfigPath)) + if err != nil { + t.Fatalf("read persisted authentication configuration: %v", err) + } + authEnvText := string(authEnv) + if !strings.Contains(authEnvText, "PULSE_AUTH_USER='bootstrap'\n") { + t.Fatalf("persisted authentication configuration missing username:\n%s", authEnvText) + } + if !strings.Contains(authEnvText, "PULSE_AUTH_PASS='$2") { + t.Fatalf("persisted authentication configuration missing password hash:\n%s", authEnvText) + } } func TestQuickSecuritySetupRejectsUnsafeUsernamesBeforeStateChanges(t *testing.T) {