mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Persist first-run auth for systemd installs
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user