Persist first-run auth for systemd installs

This commit is contained in:
courtmanr@gmail.com
2026-08-11 15:19:54 +01:00
parent 1d0a0b7f7e
commit b3fdab4cae
6 changed files with 88 additions and 21 deletions
@@ -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.
@@ -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
@@ -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
+48
View File
@@ -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
+13 -21
View File
@@ -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()
+12
View File
@@ -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) {