mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-23 07:53:36 +00:00
The privileged-session gate (ensureAdminSession) and the settings capabilities snapshot only accepted a session whose username equals the configured local admin identity. SSO users are keyed by their provider-scoped principal (sso:oidc:...), which can never match, so an SSO session was locked out of every settings-scoped route no matter what roles it held. That made group role mappings (group=admin) appear broken (#1535) and surfaced as 'Unable to load SSO providers' when an SSO user opened the SSO settings panel (#1533). A session user now passes the gate when their effective RBAC permissions include the admin action on all resources, which is the shape the built-in Administrator role assigns through group role mappings. On an instance with no local admin identity configured at all (the v5 OIDC-only pattern), SSO sessions pass as before the rewrite, since they are the only administrators the instance has. Org-scoped tenant sessions keep their own management rules. Refs #1533, #1535
828 lines
27 KiB
Go
828 lines
27 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
|
|
}
|
|
|
|
// sessionUserCarriesAdminPrivileges reports whether a non-org-scoped session
|
|
// username carries instance admin privileges: the configured local admin
|
|
// identity, an RBAC assignment granting the admin action (how SSO group role
|
|
// mappings make an SSO user an admin, #1533/#1535), or any SSO principal when
|
|
// the instance has no local admin configured at all — the v5 OIDC-only
|
|
// pattern, where SSO sessions are the only administrators the instance has.
|
|
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 configuredAdmin == "" && strings.HasPrefix(sessionUser, "sso:")
|
|
}
|
|
|
|
// 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 := json.NewDecoder(req.Body).Decode(&setupRequest); err != nil {
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
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)
|
|
|
|
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()
|
|
r.config.AuthUser = setupRequest.Username
|
|
r.config.AuthPass = hashedPassword
|
|
r.config.APITokens = []config.APITokenRecord{*tokenRecord}
|
|
r.config.SortAPITokens()
|
|
config.Mu.Unlock()
|
|
|
|
if r.persistence != nil {
|
|
if err := r.persistence.SaveAPITokens(r.config.APITokens); err != nil {
|
|
log.Warn().Err(err).Msg("Failed to persist API tokens during security setup")
|
|
}
|
|
}
|
|
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 {
|
|
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{}{
|
|
"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
|
|
|
|
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,
|
|
"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,
|
|
"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.",
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
setupCompleted = true
|
|
json.NewEncoder(w).Encode(response)
|
|
|
|
} 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()
|
|
|
|
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()
|
|
r.config.APITokens = []config.APITokenRecord{*tokenRecord}
|
|
r.config.SortAPITokens()
|
|
config.Mu.Unlock()
|
|
log.Info().Msg("Runtime config updated with new API token - active immediately")
|
|
|
|
if r.persistence != nil {
|
|
if err := r.persistence.SaveAPITokens(r.config.APITokens); err != nil {
|
|
log.Warn().Err(err).Msg("Failed to persist regenerated API token")
|
|
}
|
|
}
|
|
|
|
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 := json.NewDecoder(rq.Body).Decode(&validateRequest); err != nil {
|
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
|
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)
|
|
}
|