mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 12:42:49 +00:00
7dd5982ace
First-run setup saved the configured administrator but left the router authorizer on its startup identity. This split Settings capability checks: administrator-only panels remained visible while API Access and Pulse Intelligence were denied until restart. Keep the captured authorizer aligned when setup commits the identity, and clear its prior bypass on successful development reset. Cover the exact split with real file-backed RBAC policy and session setup, preserve outsider denial and identity replacement, and synchronise concurrent policy reads. Extension implementations still require independent compatibility review; this is not installation or release acceptance. Change-source: pulse-maintainer
861 lines
29 KiB
Go
861 lines
29 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/updates"
|
|
internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// detectServiceName detects the actual systemd service name being used
|
|
func detectServiceName() string {
|
|
// Try common service names
|
|
services := []string{"pulse-backend", "pulse", "pulse.service", "pulse-backend.service"}
|
|
|
|
for _, service := range services {
|
|
cmd := exec.Command("systemctl", "status", service)
|
|
if err := cmd.Run(); err == nil {
|
|
// Service exists
|
|
if strings.HasSuffix(service, ".service") {
|
|
return strings.TrimSuffix(service, ".service")
|
|
}
|
|
return service
|
|
}
|
|
}
|
|
|
|
// Default to pulse-backend if no service found
|
|
return "pulse-backend"
|
|
}
|
|
|
|
// validateBcryptHash ensures the hash is complete (60 characters)
|
|
func validateBcryptHash(hash string) error {
|
|
if len(hash) != 60 {
|
|
return fmt.Errorf("invalid bcrypt hash: expected 60 characters, got %d. Hash may be truncated", len(hash))
|
|
}
|
|
if !strings.HasPrefix(hash, "$2a$") && !strings.HasPrefix(hash, "$2b$") && !strings.HasPrefix(hash, "$2y$") {
|
|
return fmt.Errorf("invalid bcrypt hash: must start with $2a$, $2b$, or $2y$")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
const maxLocalAuthUsernameRunes = 128
|
|
|
|
func validateLocalAuthUsername(username string) error {
|
|
if username == "" {
|
|
return fmt.Errorf("username is required")
|
|
}
|
|
if username != strings.TrimSpace(username) {
|
|
return fmt.Errorf("username must not begin or end with whitespace")
|
|
}
|
|
if utf8.RuneCountInString(username) > maxLocalAuthUsernameRunes {
|
|
return fmt.Errorf("username must be at most %d characters", maxLocalAuthUsernameRunes)
|
|
}
|
|
|
|
for _, r := range username {
|
|
if unicode.IsLetter(r) || unicode.IsDigit(r) || strings.ContainsRune("._@+-", r) {
|
|
continue
|
|
}
|
|
return fmt.Errorf("username may contain only letters, numbers, periods, underscores, hyphens, plus signs, and @")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func quoteAuthEnvValue(value string) (string, error) {
|
|
if strings.ContainsAny(value, "'\r\n\x00") {
|
|
return "", fmt.Errorf("auth environment value contains unsupported characters")
|
|
}
|
|
return "'" + value + "'", nil
|
|
}
|
|
|
|
func renderAuthEnvFile(generatedAt time.Time, username, hashedPassword string) ([]byte, error) {
|
|
quotedUsername, err := quoteAuthEnvValue(username)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
quotedPassword, err := quoteAuthEnvValue(hashedPassword)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return []byte(fmt.Sprintf(`# Auto-generated by Pulse Quick Security Setup
|
|
# Generated on %s
|
|
# IMPORTANT: Keep authentication values quoted.
|
|
PULSE_AUTH_USER=%s
|
|
PULSE_AUTH_PASS=%s
|
|
PULSE_AUDIT_LOG=true
|
|
`, generatedAt.Format(time.RFC3339), quotedUsername, quotedPassword)), nil
|
|
}
|
|
|
|
func quoteSystemdEnvironment(name, value string) (string, error) {
|
|
if strings.ContainsAny(name, "=\r\n\x00") || strings.ContainsAny(value, "\r\n\x00") {
|
|
return "", fmt.Errorf("systemd environment value contains unsupported characters")
|
|
}
|
|
return "Environment=" + strconv.Quote(name+"="+value), nil
|
|
}
|
|
|
|
func renderSystemdAuthOverride(generatedAt time.Time, username, hashedPassword string) ([]byte, error) {
|
|
usernameLine, err := quoteSystemdEnvironment("PULSE_AUTH_USER", username)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
passwordLine, err := quoteSystemdEnvironment("PULSE_AUTH_PASS", hashedPassword)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
auditLine, err := quoteSystemdEnvironment("PULSE_AUDIT_LOG", "true")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return []byte(fmt.Sprintf(`# Auto-generated by Pulse Quick Security Setup
|
|
# Generated on %s
|
|
[Service]
|
|
%s
|
|
%s
|
|
%s
|
|
`, generatedAt.Format(time.RFC3339), usernameLine, passwordLine, auditLine)), nil
|
|
}
|
|
|
|
// isRunningAsRoot checks if the process has root privileges
|
|
func isRunningAsRoot() bool {
|
|
return os.Geteuid() == 0
|
|
}
|
|
|
|
func ensureAdminSession(cfg *config.Config, w http.ResponseWriter, req *http.Request) bool {
|
|
// Session users must match configured admin identity for privileged operations.
|
|
if cookie, err := readSessionCookie(req); err == nil && cookie.Value != "" && ValidateSession(cookie.Value) {
|
|
sessionUser := strings.TrimSpace(GetSessionUsername(cookie.Value))
|
|
configuredAdmin := ""
|
|
if cfg != nil {
|
|
configuredAdmin = strings.TrimSpace(cfg.AuthUser)
|
|
}
|
|
|
|
if configuredAdmin != "" && strings.EqualFold(sessionUser, configuredAdmin) {
|
|
return true
|
|
}
|
|
|
|
// Org-scoped tenant sessions preserve canonical org management
|
|
// privileges for settings-bound routes.
|
|
orgScoped := false
|
|
if org := GetOrganization(req.Context()); org != nil {
|
|
orgID := strings.TrimSpace(org.ID)
|
|
if orgID != "" && orgID != "default" {
|
|
orgScoped = true
|
|
if org.CanUserIDManage(sessionUser) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
if !orgScoped && sessionUserCarriesAdminPrivileges(cfg, sessionUser) {
|
|
return true
|
|
}
|
|
|
|
if configuredAdmin == "" || !strings.EqualFold(sessionUser, configuredAdmin) {
|
|
log.Warn().
|
|
Str("path", req.URL.Path).
|
|
Str("user", sessionUser).
|
|
Msg("Session user missing admin privileges for privileged operation")
|
|
http.Error(w, "Admin privileges required", http.StatusForbidden)
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// sessionIsOrgScoped reports whether the request is bound to a tenant
|
|
// organization rather than the instance itself. Instance-admin rules must not
|
|
// be applied to an org-scoped session: those callers are governed by their
|
|
// organization's own management rules rather than global RBAC assignments.
|
|
func sessionIsOrgScoped(req *http.Request) bool {
|
|
if req == nil {
|
|
return false
|
|
}
|
|
org := GetOrganization(req.Context())
|
|
if org == nil {
|
|
return false
|
|
}
|
|
orgID := strings.TrimSpace(org.ID)
|
|
return orgID != "" && orgID != "default"
|
|
}
|
|
|
|
// sessionUserCarriesAdminPrivileges reports whether a non-org-scoped session
|
|
// username carries instance admin privileges: the configured local admin
|
|
// identity or an RBAC assignment granting the admin action on all resources.
|
|
// SSO authentication alone never grants instance administration; SSO-only
|
|
// deployments must map a trusted identity-provider group to an administrator
|
|
// role or retain a configured local administrator.
|
|
func sessionUserCarriesAdminPrivileges(cfg *config.Config, sessionUser string) bool {
|
|
sessionUser = strings.TrimSpace(sessionUser)
|
|
if sessionUser == "" {
|
|
return false
|
|
}
|
|
configuredAdmin := ""
|
|
if cfg != nil {
|
|
configuredAdmin = strings.TrimSpace(cfg.AuthUser)
|
|
}
|
|
if configuredAdmin != "" && strings.EqualFold(sessionUser, configuredAdmin) {
|
|
return true
|
|
}
|
|
if sessionUserHasRBACAdminGrant(sessionUser) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// sessionUserHasRBACAdminGrant reports whether the user's effective RBAC
|
|
// permissions include an allow of the admin action on all resources — the
|
|
// shape of the built-in Administrator role that SSO group role mappings
|
|
// assign. A missing manager or empty assignment simply reports false.
|
|
func sessionUserHasRBACAdminGrant(username string) bool {
|
|
username = strings.TrimSpace(username)
|
|
if username == "" {
|
|
return false
|
|
}
|
|
manager := internalauth.GetManager()
|
|
if manager == nil {
|
|
return false
|
|
}
|
|
for _, perm := range manager.GetUserPermissions(username) {
|
|
if strings.EqualFold(perm.Effect, internalauth.EffectDeny) {
|
|
continue
|
|
}
|
|
if perm.Action == "admin" && perm.Resource == "*" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func ensureSettingsScope(cfg *config.Config, w http.ResponseWriter, req *http.Request, scope string) bool {
|
|
record := getAPITokenRecordFromRequest(req)
|
|
if record != nil {
|
|
if record.HasScope(scope) {
|
|
return true
|
|
}
|
|
|
|
log.Warn().
|
|
Str("token_id", record.ID).
|
|
Str("path", req.URL.Path).
|
|
Str("required_scope", scope).
|
|
Msg("API token missing required settings scope for privileged operation")
|
|
respondMissingScope(w, scope)
|
|
return false
|
|
}
|
|
|
|
if !ensureAdminSession(cfg, w, req) {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
func ensureSettingsReadScope(cfg *config.Config, w http.ResponseWriter, req *http.Request) bool {
|
|
return ensureSettingsScope(cfg, w, req, config.ScopeSettingsRead)
|
|
}
|
|
|
|
func ensureSettingsWriteScope(cfg *config.Config, w http.ResponseWriter, req *http.Request) bool {
|
|
return ensureSettingsScope(cfg, w, req, config.ScopeSettingsWrite)
|
|
}
|
|
|
|
// handleQuickSecuritySetupFixed is the fixed version of the Quick Security Setup
|
|
func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
// Apply rate limiting to prevent brute force attacks
|
|
clientIP := GetClientIP(req)
|
|
if !authLimiter.Allow(clientIP) {
|
|
log.Warn().Str("ip", clientIP).Msg("Rate limit exceeded for security setup")
|
|
http.Error(w, "Too many attempts. Please try again later.", http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
|
|
// Parse request body
|
|
var setupRequest struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
APIToken string `json:"apiToken"`
|
|
EnableNotifications bool `json:"enableNotifications"`
|
|
DarkMode bool `json:"darkMode"`
|
|
Force bool `json:"force"`
|
|
SetupToken string `json:"setupToken"`
|
|
}
|
|
|
|
if err := decodeSecurityRequestBody(w, req, &setupRequest); err != nil {
|
|
http.Error(w, "Invalid request body", securityRequestErrorStatus(err))
|
|
return
|
|
}
|
|
|
|
authConfigured := r.config.AuthUser != "" && r.config.AuthPass != ""
|
|
setupCompleted := false
|
|
defer func() {
|
|
if setupCompleted {
|
|
r.clearBootstrapToken()
|
|
}
|
|
}()
|
|
forceRequested := setupRequest.Force
|
|
|
|
clientIP = GetClientIP(req)
|
|
recoveryToken := strings.TrimSpace(req.Header.Get("X-Recovery-Token"))
|
|
recoveryAuthorized := false
|
|
if recoveryToken != "" {
|
|
if GetRecoveryTokenStore().ValidateRecoveryTokenConstantTime(recoveryToken, clientIP) {
|
|
recoveryAuthorized = true
|
|
log.Warn().
|
|
Str("ip", clientIP).
|
|
Msg("Quick security setup invoked using recovery token")
|
|
} else {
|
|
log.Warn().
|
|
Str("ip", clientIP).
|
|
Msg("Invalid recovery token for quick security setup")
|
|
}
|
|
}
|
|
|
|
authorized := recoveryAuthorized
|
|
|
|
// Only require authentication if credentials are already configured.
|
|
if !authorized && authConfigured {
|
|
wrapped := &responseCapture{ResponseWriter: w}
|
|
if checkAuth(r.config, wrapped, req, false) {
|
|
// If proxy auth is configured, require admin role for changes.
|
|
if r.config.ProxyAuthSecret != "" {
|
|
if valid, username, isAdmin := CheckProxyAuth(r.config, req); valid && !isAdmin {
|
|
log.Warn().
|
|
Str("ip", clientIP).
|
|
Str("username", username).
|
|
Msg("Non-admin user attempted quick security setup")
|
|
http.Error(w, "Admin privileges required", http.StatusForbidden)
|
|
return
|
|
}
|
|
}
|
|
authorized = true
|
|
} else {
|
|
if !wrapped.wrote {
|
|
http.Error(w, "Authentication required to modify existing security settings", http.StatusUnauthorized)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
if !authorized && !authConfigured {
|
|
if r.bootstrapTokenHash == "" {
|
|
log.Error().Msg("Bootstrap setup token unavailable; refusing unauthenticated quick setup")
|
|
http.Error(w, "Bootstrap token unavailable; restart Pulse or inspect data directory", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
providedToken := strings.TrimSpace(req.Header.Get(bootstrapTokenHeader))
|
|
if providedToken == "" {
|
|
providedToken = strings.TrimSpace(setupRequest.SetupToken)
|
|
}
|
|
|
|
// The bootstrap token is the security boundary for initial setup:
|
|
// only callers with filesystem access to the data dir can read it.
|
|
// A valid token authorizes setup from any origin so users running
|
|
// Pulse in a Proxmox LXC (the common case) can complete setup from
|
|
// their workstation browser. Without a token, only direct loopback
|
|
// can finish setup — that lane preserves the legacy console flow.
|
|
if providedToken == "" {
|
|
if !isDirectLoopbackRequest(req) {
|
|
log.Warn().
|
|
Str("ip", clientIP).
|
|
Msg("Rejected initial quick setup: no bootstrap token from non-loopback origin")
|
|
errorMsg := "Initial security setup requires the bootstrap token when accessed outside localhost. Retrieve it from the host:\n\n" +
|
|
"Docker: docker exec <container> /app/pulse bootstrap-token\n" +
|
|
"Bare metal: pulse bootstrap-token"
|
|
http.Error(w, errorMsg, http.StatusForbidden)
|
|
return
|
|
}
|
|
errorMsg := "Bootstrap setup token required. Retrieve it from the host:\n\n" +
|
|
"Docker: docker exec <container> /app/pulse bootstrap-token\n" +
|
|
"Bare metal: pulse bootstrap-token"
|
|
http.Error(w, errorMsg, http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
if !r.bootstrapTokenValid(providedToken) {
|
|
log.Warn().
|
|
Str("ip", clientIP).
|
|
Msg("Rejected quick setup with invalid bootstrap token")
|
|
errorMsg := "Invalid bootstrap setup token. Retrieve the correct token from the host:\n\n" +
|
|
"Docker: docker exec <container> /app/pulse bootstrap-token\n" +
|
|
"Bare metal: pulse bootstrap-token"
|
|
http.Error(w, errorMsg, http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
authorized = true
|
|
}
|
|
|
|
if authConfigured && !authorized {
|
|
log.Warn().
|
|
Str("ip", clientIP).
|
|
Msg("Unauthorized quick security setup attempt rejected")
|
|
http.Error(w, "Authentication required to modify existing security settings", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
if authorized && !ensureSettingsWriteScope(r.config, w, req) {
|
|
return
|
|
}
|
|
|
|
setupRequest.Force = forceRequested && authorized
|
|
|
|
if authConfigured && !setupRequest.Force {
|
|
log.Info().Msg("Security setup skipped - password auth already configured")
|
|
response := map[string]interface{}{
|
|
"success": true,
|
|
"skipped": true,
|
|
"message": "Password authentication is already configured. Please remove existing security first if you want to reconfigure.",
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
return
|
|
}
|
|
|
|
if setupRequest.Force {
|
|
log.Info().Msg("Quick security setup invoked with force=true - rotating credentials")
|
|
}
|
|
|
|
// Validate inputs before any runtime or persistent security state changes.
|
|
if setupRequest.Username == "" || setupRequest.Password == "" || setupRequest.APIToken == "" {
|
|
http.Error(w, "Username, password, and API token are required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if err := validateLocalAuthUsername(setupRequest.Username); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Validate password complexity
|
|
if err := internalauth.ValidatePasswordComplexity(setupRequest.Password); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Hash the password
|
|
hashedPassword, err := internalauth.HashPassword(setupRequest.Password)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to hash password")
|
|
http.Error(w, "Failed to process password", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Validate the bcrypt hash is complete
|
|
if err := validateBcryptHash(hashedPassword); err != nil {
|
|
log.Error().Err(err).Msg("Generated invalid bcrypt hash")
|
|
http.Error(w, "Failed to process password", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
authEnvContent, err := renderAuthEnvFile(time.Now(), setupRequest.Username, hashedPassword)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to encode authentication configuration")
|
|
http.Error(w, "Failed to save security configuration", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Store the raw API token for displaying to the user
|
|
rawAPIToken := setupRequest.APIToken
|
|
|
|
tokenRecord, err := config.NewAPITokenRecord(rawAPIToken, "Primary token", nil)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to construct API token record")
|
|
http.Error(w, "Failed to process API token", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
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")
|
|
}
|
|
|
|
// Update runtime config immediately with hashed token - no restart needed!
|
|
config.Mu.Lock()
|
|
previousTokens := append([]config.APITokenRecord(nil), r.config.APITokens...)
|
|
r.config.AuthUser = setupRequest.Username
|
|
// Keep permission-gated routes aligned with the configured administrator
|
|
// immediately, including when token persistence below fails after the
|
|
// password configuration has already been saved.
|
|
r.syncConfiguredAdminAuthorizer()
|
|
r.config.AuthPass = hashedPassword
|
|
r.config.APITokens = []config.APITokenRecord{*tokenRecord}
|
|
r.config.SortAPITokens()
|
|
if r.persistence != nil {
|
|
if err := r.persistence.SaveAPITokens(r.config.APITokens); err != nil {
|
|
r.config.APITokens = previousTokens
|
|
r.config.SortAPITokens()
|
|
config.Mu.Unlock()
|
|
log.Error().Err(err).Msg("Failed to persist API tokens during security setup")
|
|
http.Error(w, "Password authentication was saved, but the API token could not be saved", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
config.Mu.Unlock()
|
|
log.Info().Msg("Runtime config updated with new security settings - active immediately")
|
|
|
|
if err := r.establishSession(w, req, setupRequest.Username); err != nil {
|
|
log.Error().Err(err).Msg("Failed to establish session after quick security setup")
|
|
http.Error(w, "Failed to establish session after setup", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Clear any agents that connected during the brief unauthenticated setup window.
|
|
// This prevents stale/unauthorized agent data from appearing in the wizard.
|
|
if r.monitor != nil {
|
|
hostCleared, dockerCleared := r.monitor.ClearUnauthenticatedAgents()
|
|
if hostCleared > 0 || dockerCleared > 0 {
|
|
log.Info().
|
|
Int("hosts", hostCleared).
|
|
Int("dockerHosts", dockerCleared).
|
|
Msg("Cleared agents that connected before security was configured")
|
|
}
|
|
}
|
|
|
|
// Save system settings to system.json
|
|
systemSettings := config.DefaultSystemSettings()
|
|
systemSettings.ConnectionTimeout = 10 // Default seconds
|
|
systemSettings.AutoUpdateEnabled = false // Default disabled
|
|
if err := r.persistence.SaveSystemSettings(*systemSettings); err != nil {
|
|
log.Error().Err(err).Msg("Failed to save system settings")
|
|
// Continue anyway - not critical for auth setup
|
|
}
|
|
|
|
// Detect environment
|
|
isSystemd := os.Getenv("INVOCATION_ID") != ""
|
|
isDocker := os.Getenv("PULSE_DOCKER") == "true"
|
|
isRoot := isRunningAsRoot()
|
|
|
|
// Detect actual service name if systemd
|
|
serviceName := ""
|
|
if isSystemd {
|
|
serviceName = detectServiceName()
|
|
log.Info().Str("service", serviceName).Msg("Detected systemd service name")
|
|
}
|
|
|
|
// Choose appropriate method based on environment
|
|
if isDocker {
|
|
log.Info().Str("path", envPath).Msg("Docker security configuration saved")
|
|
|
|
response := map[string]interface{}{
|
|
"success": true,
|
|
"method": "docker",
|
|
"deploymentType": "docker",
|
|
"requiresManualRestart": false,
|
|
"message": "Security enabled immediately! Your settings are saved and active.",
|
|
"note": "Configuration saved to /data/.env for persistence across restarts.",
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
setupCompleted = true
|
|
json.NewEncoder(w).Encode(response)
|
|
|
|
} else if isSystemd && !isRoot {
|
|
// Systemd but not root (ProxmoxVE script scenario)
|
|
// Don't attempt sudo, just save config and provide instructions
|
|
|
|
// Create response - security is active immediately
|
|
response := map[string]interface{}{
|
|
"success": true,
|
|
"method": "systemd-nonroot",
|
|
"serviceName": serviceName,
|
|
"envFile": envPath,
|
|
"deploymentType": updates.GetDeploymentType(),
|
|
"requiresManualRestart": false,
|
|
"message": "Security enabled immediately! Your settings are saved and active.",
|
|
"note": fmt.Sprintf("Configuration saved to %s for persistence across restarts.", envPath),
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
setupCompleted = true
|
|
json.NewEncoder(w).Encode(response)
|
|
|
|
} else if isSystemd && isRoot {
|
|
// Systemd with root - can apply directly
|
|
|
|
// Create systemd override
|
|
overridePath := fmt.Sprintf("/etc/systemd/system/%s.service.d/override.conf", serviceName)
|
|
overrideDir := filepath.Dir(overridePath)
|
|
|
|
if err := os.MkdirAll(overrideDir, 0755); err != nil {
|
|
log.Error().Err(err).Msg("Failed to create override directory")
|
|
http.Error(w, "Failed to create systemd override directory", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
overrideContent, err := renderSystemdAuthOverride(time.Now(), setupRequest.Username, hashedPassword)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to encode systemd authentication configuration")
|
|
http.Error(w, "Failed to save security configuration", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if err := os.WriteFile(overridePath, overrideContent, 0644); err != nil {
|
|
log.Error().Err(err).Msg("Failed to write systemd override")
|
|
http.Error(w, "Failed to write systemd override", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Reload systemd
|
|
if err := exec.Command("systemctl", "daemon-reload").Run(); err != nil {
|
|
log.Warn().Err(err).Msg("Failed to reload systemd daemon")
|
|
}
|
|
|
|
response := map[string]interface{}{
|
|
"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": "Authentication file and systemd override created for persistence across restarts.",
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
setupCompleted = true
|
|
json.NewEncoder(w).Encode(response)
|
|
|
|
} else {
|
|
// Manual installation or development
|
|
// Get deployment type for restart instructions
|
|
deploymentType := updates.GetDeploymentType()
|
|
|
|
response := map[string]interface{}{
|
|
"success": true,
|
|
"method": "manual",
|
|
"envFile": envPath,
|
|
"deploymentType": deploymentType,
|
|
"requiresManualRestart": false,
|
|
"message": "Security enabled immediately! Your settings are saved and active.",
|
|
"note": fmt.Sprintf("Configuration saved to %s for persistence across restarts.", envPath),
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
setupCompleted = true
|
|
json.NewEncoder(w).Encode(response)
|
|
}
|
|
}
|
|
}
|
|
|
|
// HandleRegenerateAPIToken generates and persists a new API token.
|
|
func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Request) {
|
|
if rq.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
if !CheckAuth(r.config, w, rq) {
|
|
return
|
|
}
|
|
|
|
// Check proxy auth for admin status
|
|
if r.config.ProxyAuthSecret != "" {
|
|
if valid, username, isAdmin := CheckProxyAuth(r.config, rq); valid && !isAdmin {
|
|
log.Warn().
|
|
Str("ip", GetClientIP(rq)).
|
|
Str("username", username).
|
|
Msg("Non-admin user attempted API token regeneration")
|
|
http.Error(w, "Admin privileges required", http.StatusForbidden)
|
|
return
|
|
}
|
|
}
|
|
|
|
if !ensureSettingsWriteScope(r.config, w, rq) {
|
|
return
|
|
}
|
|
|
|
// Apply rate limiting to prevent abuse
|
|
clientIP := GetClientIP(rq)
|
|
if !authLimiter.Allow(clientIP) {
|
|
log.Warn().Str("ip", clientIP).Msg("Rate limit exceeded for API token generation")
|
|
http.Error(w, "Too many attempts. Please try again later.", http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
|
|
// Generate new token using the auth package
|
|
rawToken, err := internalauth.GenerateAPIToken()
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to generate API token")
|
|
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
tokenRecord, err := config.NewAPITokenRecord(rawToken, "Regenerated token", nil)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to construct API token record")
|
|
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
setAPITokenOwnerUserID(tokenRecord, apiTokenOwnerUserIDForRequest(r.config, rq))
|
|
|
|
config.Mu.Lock()
|
|
previousTokens := append([]config.APITokenRecord(nil), r.config.APITokens...)
|
|
r.config.APITokens = []config.APITokenRecord{*tokenRecord}
|
|
r.config.SortAPITokens()
|
|
if r.persistence != nil {
|
|
if err := r.persistence.SaveAPITokens(r.config.APITokens); err != nil {
|
|
r.config.APITokens = previousTokens
|
|
r.config.SortAPITokens()
|
|
config.Mu.Unlock()
|
|
log.Error().Err(err).Msg("Failed to persist regenerated API token")
|
|
http.Error(w, "Failed to save token", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
config.Mu.Unlock()
|
|
log.Info().Msg("Runtime config updated with new API token - active immediately")
|
|
|
|
log.Info().Msg("API token regenerated successfully")
|
|
|
|
// Get deployment type for restart instructions
|
|
deploymentType := updates.GetDeploymentType()
|
|
|
|
response := map[string]interface{}{
|
|
"success": true,
|
|
"token": rawToken, // Return the raw token to the user (only shown once!)
|
|
"deploymentType": deploymentType,
|
|
"requiresRestart": false,
|
|
"message": "New API token generated and active immediately! Save this token - it won't be shown again.",
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
}
|
|
|
|
// HandleValidateAPIToken validates an API token without logging it
|
|
func (r *Router) HandleValidateAPIToken(w http.ResponseWriter, rq *http.Request) {
|
|
if rq.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
// Require authentication to prevent unauthenticated token guessing oracle
|
|
if !CheckAuth(r.config, w, rq) {
|
|
return
|
|
}
|
|
|
|
// Check proxy auth for admin status
|
|
if r.config.ProxyAuthSecret != "" {
|
|
if valid, username, isAdmin := CheckProxyAuth(r.config, rq); valid && !isAdmin {
|
|
log.Warn().
|
|
Str("ip", GetClientIP(rq)).
|
|
Str("username", username).
|
|
Msg("Non-admin user attempted API token validation")
|
|
http.Error(w, "Admin privileges required", http.StatusForbidden)
|
|
return
|
|
}
|
|
}
|
|
|
|
if !ensureSettingsWriteScope(r.config, w, rq) {
|
|
return
|
|
}
|
|
|
|
// Apply rate limiting to prevent brute force attacks
|
|
clientIP := GetClientIP(rq)
|
|
if !authLimiter.Allow(clientIP) {
|
|
log.Warn().Str("ip", clientIP).Msg("Rate limit exceeded for API token validation")
|
|
http.Error(w, "Too many attempts. Please try again later.", http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
|
|
// Parse request body
|
|
var validateRequest struct {
|
|
Token string `json:"token"`
|
|
}
|
|
|
|
if err := decodeSecurityRequestBody(w, rq, &validateRequest); err != nil {
|
|
http.Error(w, "Invalid request body", securityRequestErrorStatus(err))
|
|
return
|
|
}
|
|
|
|
if validateRequest.Token == "" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"valid": false,
|
|
"message": "Token is required",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Check if API token auth is enabled
|
|
if !r.config.HasAPITokens() {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"valid": false,
|
|
"message": "API token authentication is not configured",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Validate the token (compare hash)
|
|
config.Mu.RLock()
|
|
_, isValid := r.config.ValidateAPIToken(validateRequest.Token)
|
|
config.Mu.RUnlock()
|
|
|
|
// Log validation attempt without logging the token itself
|
|
if isValid {
|
|
log.Debug().
|
|
Str("ip", clientIP).
|
|
Msg("API token validation successful")
|
|
} else {
|
|
log.Warn().
|
|
Str("ip", clientIP).
|
|
Msg("API token validation failed")
|
|
}
|
|
|
|
// Return validation result
|
|
response := map[string]interface{}{
|
|
"valid": isValid,
|
|
}
|
|
|
|
if isValid {
|
|
response["message"] = "Token is valid"
|
|
} else {
|
|
response["message"] = "Token is invalid"
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
}
|
|
|
|
// syncConfiguredAdminAuthorizer updates the same authorizer captured by this
|
|
// router's permission gates. Call while changing AuthUser under config.Mu, or
|
|
// during router construction before serving requests. Empty clears a prior
|
|
// configured-admin grant; it must not survive first-run reset.
|
|
func (r *Router) syncConfiguredAdminAuthorizer() {
|
|
if configurable, ok := r.authorizer.(internalauth.AdminConfigurable); ok {
|
|
configurable.SetAdminUser(r.config.AuthUser)
|
|
}
|
|
}
|