mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 05:29:45 +00:00
5fb27d4039
The organization-owner fix judged browser sessions only. API tokens on instance routes were still checked for scope alone, and the tenant middleware resolves an org-bound token to its own organization without the caller naming one. A token bound to a tenant organization and holding settings:write therefore reached system settings, SSO providers, token management, relay, log level, updates and the other instance surfaces. ensureAdminSession, behind RequireAdmin, and ensureSettingsScope now refuse a token unless it acts for the instance: not bound to any organization other than the default one, with OrgIDs taking precedence over OrgID. The route's scope still applies. The org variants (RequireOrgAdmin, ensureOrgAdminSession, ensureOrgSettingsWriteScope) keep judging tokens by scope alone. tokenHasInstanceWideAuthority is now the same binding rule plus full-access scope. The test notification broadcasts only to the request organization, so it moves to RequireOrgAdmin. Its tenant delivery test already relied on an org-bound token sending it, and that organization's managers may now send it from a session too. No path an organization manager can reach mints a settings scope: relay mobile, install, deploy and action-runner tokens carry relay, agent and report scopes only, and token management is instance administration. The API reference and the api-contracts, security-privacy, agent-lifecycle and storage-recovery contracts record the token rule.
1007 lines
36 KiB
Go
1007 lines
36 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
|
|
}
|
|
|
|
// ensureAdminSession admits a session only when it belongs to an instance
|
|
// administrator: the configured local admin or a user holding an instance RBAC
|
|
// admin grant, whichever organization the request names. It is
|
|
// the session check behind RequireAdmin and the settings scope helpers, so it
|
|
// guards everything that reads or changes instance-wide state: credentials,
|
|
// API tokens, SSO, system settings, updates, relay, logs and diagnostics.
|
|
//
|
|
// Organization owners and admins are not instance administrators. A route
|
|
// that serves only the request's own organization opts into their authority
|
|
// explicitly with ensureOrgAdminSession or RequireOrgAdmin; the default is
|
|
// instance administration, so a route that forgets to choose fails closed.
|
|
//
|
|
// API tokens follow the same boundary. A token bound to a tenant organization
|
|
// acts for that organization, like its owner, so it is refused here whatever
|
|
// scopes it holds; the route's scope check stays with the caller. Requests
|
|
// without a token or a session cookie are left to the caller's other checks.
|
|
func ensureAdminSession(cfg *config.Config, w http.ResponseWriter, req *http.Request) bool {
|
|
if record := getAPITokenRecordFromRequest(req); record != nil && !refuseOrgBoundToken(w, req, record) {
|
|
return false
|
|
}
|
|
cookie, err := readSessionCookie(req)
|
|
if err != nil || cookie.Value == "" || !ValidateSession(cookie.Value) {
|
|
return true
|
|
}
|
|
sessionUser := strings.TrimSpace(GetSessionUsername(cookie.Value))
|
|
if sessionUserCarriesAdminPrivileges(cfg, sessionUser) {
|
|
return true
|
|
}
|
|
|
|
log.Warn().
|
|
Str("path", req.URL.Path).
|
|
Str("user", sessionUser).
|
|
Bool("org_scoped", sessionIsOrgScoped(req)).
|
|
Msg("Session user missing instance admin privileges for privileged operation")
|
|
http.Error(w, "Admin privileges required", http.StatusForbidden)
|
|
return false
|
|
}
|
|
|
|
// ensureOrgAdminSession is the session check for routes whose handlers read and
|
|
// change only the request organization's own config and monitor state, such
|
|
// as its infrastructure connections, agents and availability targets. It admits
|
|
// every instance administrator and, on an org-scoped request, a user who can
|
|
// manage that organization, so a provider opening a client workspace as its
|
|
// owner can connect the client's systems. Everything else uses
|
|
// ensureAdminSession.
|
|
func ensureOrgAdminSession(cfg *config.Config, w http.ResponseWriter, req *http.Request) bool {
|
|
if cookie, err := readSessionCookie(req); err == nil && cookie.Value != "" && ValidateSession(cookie.Value) {
|
|
sessionUser := strings.TrimSpace(GetSessionUsername(cookie.Value))
|
|
if sessionUserCarriesAdminPrivileges(cfg, sessionUser) || sessionUserHasSettingsAdminPrivileges(cfg, req, sessionUser) {
|
|
return true
|
|
}
|
|
|
|
log.Warn().
|
|
Str("path", req.URL.Path).
|
|
Str("user", sessionUser).
|
|
Msg("Session user missing admin privileges for organization operation")
|
|
http.Error(w, "Admin privileges required", http.StatusForbidden)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// sessionUserHasSettingsAdminPrivileges is the session rule ensureOrgAdminSession
|
|
// enforces for org-bound routes: the configured local admin; on an
|
|
// org-scoped request, a user who can manage that organization; otherwise an
|
|
// instance administrator. The security status snapshot publishes settings
|
|
// capabilities from this same rule, so a surface the UI offers is one its
|
|
// routes accept.
|
|
//
|
|
// It is deliberately wider than the platform admin rule. An org owner
|
|
// administers their own organization's settings but is never an instance or
|
|
// platform administrator; RequirePlatformAdmin and the billingAdmin capability
|
|
// keep excluding org-scoped sessions.
|
|
func sessionUserHasSettingsAdminPrivileges(cfg *config.Config, req *http.Request, 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
|
|
}
|
|
|
|
// Org-scoped tenant sessions preserve canonical org management
|
|
// privileges for settings-bound routes.
|
|
if sessionIsOrgScoped(req) {
|
|
org := GetOrganization(req.Context())
|
|
return org != nil && org.CanUserIDManage(sessionUser)
|
|
}
|
|
|
|
return sessionUserCarriesAdminPrivileges(cfg, sessionUser)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// ensureSettingsScope guards instance administration. A token caller needs the
|
|
// scope and instance-wide authority (refuseOrgBoundToken); a session caller
|
|
// must pass ensureAdminSession.
|
|
func ensureSettingsScope(cfg *config.Config, w http.ResponseWriter, req *http.Request, scope string) bool {
|
|
if record := getAPITokenRecordFromRequest(req); record != nil {
|
|
return ensureTokenSettingsScope(w, req, record, scope) && refuseOrgBoundToken(w, req, record)
|
|
}
|
|
|
|
if !ensureAdminSession(cfg, w, req) {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// ensureOrgSettingsScope is ensureSettingsScope for routes that act only on
|
|
// the request organization's own state: token callers need only the scope,
|
|
// and session callers pass ensureOrgAdminSession instead of ensureAdminSession.
|
|
func ensureOrgSettingsScope(cfg *config.Config, w http.ResponseWriter, req *http.Request, scope string) bool {
|
|
if record := getAPITokenRecordFromRequest(req); record != nil {
|
|
return ensureTokenSettingsScope(w, req, record, scope)
|
|
}
|
|
return ensureOrgAdminSession(cfg, w, req)
|
|
}
|
|
|
|
func ensureTokenSettingsScope(w http.ResponseWriter, req *http.Request, record *config.APITokenRecord, scope string) bool {
|
|
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
|
|
}
|
|
|
|
// refuseOrgBoundToken admits a token on an instance route only when it acts
|
|
// for the instance (tokenActsForInstance). It answers 403 otherwise.
|
|
func refuseOrgBoundToken(w http.ResponseWriter, req *http.Request, record *config.APITokenRecord) bool {
|
|
if tokenActsForInstance(record) {
|
|
return true
|
|
}
|
|
|
|
log.Warn().
|
|
Str("token_id", record.ID).
|
|
Str("path", req.URL.Path).
|
|
Msg("Organization-bound API token refused for instance administration")
|
|
http.Error(w, "Admin privileges required", http.StatusForbidden)
|
|
return false
|
|
}
|
|
|
|
func ensureOrgSettingsWriteScope(cfg *config.Config, w http.ResponseWriter, req *http.Request) bool {
|
|
return ensureOrgSettingsScope(cfg, w, req, config.ScopeSettingsWrite)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// From here quick setup sets the administrator username and password
|
|
// and replaces every API token with a full-access one. That is
|
|
// instance-wide authority, so an API-token caller needs it too;
|
|
// settings:write alone must not be enough to take the instance over.
|
|
if callerToken := getAPITokenRecordFromRequest(req); callerToken != nil && !tokenHasInstanceWideAuthority(callerToken) {
|
|
log.Warn().
|
|
Str("token_id", callerToken.ID).
|
|
Str("ip", clientIP).
|
|
Msg("API token without instance-wide authority attempted quick security setup")
|
|
http.Error(w, "Security setup replaces the administrator credentials and every API token, so it needs a full-access token that is not bound to any non-default organization.", http.StatusForbidden)
|
|
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")
|
|
}
|
|
}
|
|
|
|
// Initialize first-run defaults only; rotating authentication must not
|
|
// reset unrelated preferences or overwrite settings needing recovery.
|
|
systemSettings := config.DefaultSystemSettings()
|
|
systemSettings.ConnectionTimeout = 10 // Default seconds
|
|
systemSettings.AutoUpdateEnabled = false // Default disabled
|
|
if err := r.persistence.InitializeSystemSettings(*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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// tokenHasInstanceWideAuthority reports whether a token holds full-access scope
|
|
// and acts for the instance (tokenActsForInstance).
|
|
func tokenHasInstanceWideAuthority(record *config.APITokenRecord) bool {
|
|
return record != nil && record.HasScope(config.ScopeWildcard) && tokenActsForInstance(record)
|
|
}
|
|
|
|
// tokenActsForInstance reports whether a token is not bound to any
|
|
// organization other than the default one. Only such a token may reach
|
|
// instance administration, with whatever scope the route requires. OrgIDs
|
|
// takes precedence over OrgID, as it does for tenant access checks.
|
|
func tokenActsForInstance(record *config.APITokenRecord) bool {
|
|
if record == nil {
|
|
return false
|
|
}
|
|
isDefault := func(orgID string) bool {
|
|
orgID = strings.TrimSpace(orgID)
|
|
return orgID == "" || orgID == "default"
|
|
}
|
|
if len(record.OrgIDs) > 0 {
|
|
for _, orgID := range record.OrgIDs {
|
|
if !isDefault(orgID) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
return isDefault(record.OrgID)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Regeneration replaces the whole token inventory, wildcard tokens
|
|
// included, and issues a full-access credential. A token caller therefore
|
|
// needs instance-wide full-access authority; a scoped or organization-bound
|
|
// token replaces just itself through /api/security/tokens/{id}/rotate.
|
|
if callerToken := getAPITokenRecordFromRequest(rq); callerToken != nil && !tokenHasInstanceWideAuthority(callerToken) {
|
|
log.Warn().
|
|
Str("token_id", callerToken.ID).
|
|
Str("ip", GetClientIP(rq)).
|
|
Msg("API token without instance-wide authority attempted to regenerate every token")
|
|
http.Error(w, "Regenerating API tokens replaces every token, so it needs a full-access token that is not bound to any non-default organization. Use POST /api/security/tokens/{id}/rotate to replace one token.", http.StatusForbidden)
|
|
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)
|
|
}
|
|
}
|