package api import ( "context" "encoding/json" "errors" "net/http" "os" "strings" "sync" "time" "codeberg.org/SquidSE/ClusterCanvas/service/internal/auth" "codeberg.org/SquidSE/ClusterCanvas/service/internal/runner" "codeberg.org/SquidSE/ClusterCanvas/service/internal/settings" ) type contextKey string const ( contextUserKey contextKey = "user" contextSessionKey contextKey = "session" ) type setupStatusResponse struct { Completed bool `json:"completed"` ClientIP string `json:"client_ip"` } type setupTOTPBeginRequest struct { Username string `json:"username"` } type setupTOTPBeginResponse struct { Secret string `json:"secret"` OTPAuthURL string `json:"otpauth_url"` } type setupTOTPVerifyRequest struct { Secret string `json:"secret"` Code string `json:"code"` } type setupCompleteRequest struct { Username string `json:"username"` Password string `json:"password"` TOTPSecret string `json:"totp_secret"` TOTPConfirmed bool `json:"totp_confirmed"` Network settings.NetworkSettings `json:"network"` Security settings.SecuritySettings `json:"security"` } type setupPreviewRequest struct { Network settings.NetworkSettings `json:"network"` } type setupPreviewResponse struct { WouldLockOut bool `json:"would_lock_out"` Reason string `json:"reason,omitempty"` ClientIP string `json:"client_ip"` } type loginRequest struct { Username string `json:"username"` Password string `json:"password"` TOTPCode string `json:"totp_code"` } type meResponse struct { UserID string `json:"user_id"` Username string `json:"username"` Groups []string `json:"groups"` TOTPConfirmed bool `json:"totp_confirmed"` TOTPEnabled bool `json:"totp_enabled"` Permissions []string `json:"permissions"` } // App holds shared API dependencies. type App struct { ConfigDir string Key []byte Sessions *auth.SessionManager Executor *runner.Executor pendingTOTPMu sync.Mutex pendingTOTP map[string]string // username -> secret (setup only) } func newApp(configDir string) (*App, error) { key, err := settings.KeyFromEnv() if err != nil { // Key is required for setup complete / auth; status still works without it. app := &App{ ConfigDir: configDir, pendingTOTP: map[string]string{}, Executor: runner.NewExecutor(configDir, nil), } return app, nil } app := &App{ ConfigDir: configDir, Key: key, Sessions: auth.NewSessionManager(configDir, key), pendingTOTP: map[string]string{}, Executor: runner.NewExecutor(configDir, key), } return app, nil } func (app *App) requireKey() error { if len(app.Key) == 0 { return fmtErrorfKeyMissing() } return nil } func fmtErrorfKeyMissing() error { return errors.New(settings.ConfigKeyEnvVar + " is not set") } func (app *App) setupStatusHandler(writer http.ResponseWriter, request *http.Request) { completed, err := settings.IsSetupCompleted(app.ConfigDir) if err != nil { writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) return } writeJSON(writer, http.StatusOK, setupStatusResponse{ Completed: completed, ClientIP: auth.ClientIP(request), }) } func (app *App) setupTOTPBeginHandler(writer http.ResponseWriter, request *http.Request) { completed, err := settings.IsSetupCompleted(app.ConfigDir) if err != nil { writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) return } if completed { writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "setup already completed"}) return } var payload setupTOTPBeginRequest if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"}) return } username := strings.TrimSpace(payload.Username) if username == "" { username = "Admin" } key, err := auth.GenerateTOTPSecret(username) if err != nil { writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) return } app.pendingTOTPMu.Lock() app.pendingTOTP[strings.ToLower(username)] = key.Secret() app.pendingTOTPMu.Unlock() writeJSON(writer, http.StatusOK, setupTOTPBeginResponse{ Secret: key.Secret(), OTPAuthURL: key.URL(), }) } func (app *App) setupTOTPVerifyHandler(writer http.ResponseWriter, request *http.Request) { completed, err := settings.IsSetupCompleted(app.ConfigDir) if err != nil { writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) return } if completed { writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "setup already completed"}) return } var payload setupTOTPVerifyRequest if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"}) return } if !auth.VerifyTOTPCode(payload.Secret, payload.Code) { writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid TOTP code"}) return } writeJSON(writer, http.StatusOK, map[string]bool{"verified": true}) } func (app *App) setupPreviewHandler(writer http.ResponseWriter, request *http.Request) { var payload setupPreviewRequest if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"}) return } clientIP := auth.ClientIP(request) wouldLockOut, reason := auth.WouldLockOut(clientIP, payload.Network) writeJSON(writer, http.StatusOK, setupPreviewResponse{ WouldLockOut: wouldLockOut, Reason: reason, ClientIP: clientIP, }) } func (app *App) setupCompleteHandler(writer http.ResponseWriter, request *http.Request) { if err := app.requireKey(); err != nil { writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()}) return } completed, err := settings.IsSetupCompleted(app.ConfigDir) if err != nil { writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) return } if completed { writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "setup already completed"}) return } var payload setupCompleteRequest if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"}) return } username := strings.TrimSpace(payload.Username) if username == "" { username = "Admin" } if err := auth.ValidatePassword(payload.Password, username); err != nil { writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()}) return } if err := auth.ValidateListenAddress(payload.Network.ListenAddress); err != nil { writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()}) return } if err := auth.ValidateHostname(payload.Network.PublicHostname); err != nil { writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()}) return } if err := auth.ValidateAccessRules(payload.Network.Rules); err != nil { writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()}) return } switch payload.Network.AccessMode { case settings.AccessModeOpen, settings.AccessModeWhitelist, settings.AccessModeBlacklist: default: writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "network.access_mode must be open, whitelist, or blacklist"}) return } if err := validateSecuritySettings(payload.Security); err != nil { writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()}) return } if payload.TOTPConfirmed { if strings.TrimSpace(payload.TOTPSecret) == "" { writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "totp_secret is required when totp_confirmed is true"}) return } payload.Security.TotpEnabled = true } passwordHash, err := auth.HashPassword(payload.Password) if err != nil { writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) return } now := time.Now().UTC() userID, err := newUserID() if err != nil { writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) return } user := settings.UserCredential{ ID: userID, Username: username, PasswordHash: passwordHash, TOTPSecret: payload.TOTPSecret, TOTPConfirmed: payload.TOTPConfirmed, Enabled: true, GroupNames: []string{settings.AdministratorsGroupName}, CreatedAt: now, PasswordChangedAt: now, } passwordStore := settings.PasswordStore{Users: []settings.UserCredential{user}} if err := settings.SavePasswords(app.ConfigDir, passwordStore, app.Key); err != nil { writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) return } if err := settings.SaveSessions(app.ConfigDir, settings.SessionStore{Sessions: []settings.SessionRecord{}}, app.Key); err != nil { writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) return } adminGroup := settings.Group{ Name: settings.AdministratorsGroupName, ScopeKind: settings.GroupScopeGroup, ScopeName: "Admin Group", Permissions: allPermissionList(), } network := settings.EffectiveNetwork(payload.Network) settingsPayload := settings.Settings{ SetupCompleted: true, Groups: []settings.Group{adminGroup}, Security: payload.Security, Network: network, } if err := settings.SaveSettings(app.ConfigDir, settingsPayload); err != nil { writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) return } writeJSON(writer, http.StatusOK, map[string]any{ "completed": true, "username": username, }) } func (app *App) loginHandler(writer http.ResponseWriter, request *http.Request) { if err := app.requireKey(); err != nil { writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()}) return } if app.Sessions == nil { app.Sessions = auth.NewSessionManager(app.ConfigDir, app.Key) } completed, err := settings.IsSetupCompleted(app.ConfigDir) if err != nil { writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) return } if !completed { writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "setup is not completed"}) return } var payload loginRequest if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"}) return } store, err := settings.LoadPasswords(app.ConfigDir, app.Key) if err != nil { writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) return } var matched *settings.UserCredential for index := range store.Users { user := &store.Users[index] if strings.EqualFold(user.Username, strings.TrimSpace(payload.Username)) { matched = user break } } attemptedUsername := strings.TrimSpace(payload.Username) if matched == nil || !matched.Enabled { appendAuthAudit( app.ConfigDir, "login", settings.AuthAuditOutcomeFailure, settings.AuthAuditCategoryAuth, attemptedUsername, attemptedUsername, "invalid credentials", ) writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid credentials"}) return } ok, err := auth.VerifyPassword(payload.Password, matched.PasswordHash) if err != nil || !ok { appendAuthAudit( app.ConfigDir, "login", settings.AuthAuditOutcomeFailure, settings.AuthAuditCategoryAuth, attemptedUsername, matched.Username, "invalid credentials", ) writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid credentials"}) return } settingsPayload, err := loadSettingsOrDefault(app.ConfigDir) if err != nil { writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) return } security := effectiveSecurity(settingsPayload.Security) requiresTOTP := security.TotpEnabled && matched.TOTPConfirmed && matched.TOTPSecret != "" if requiresTOTP { if !auth.VerifyTOTPCode(matched.TOTPSecret, payload.TOTPCode) { appendAuthAudit( app.ConfigDir, "login", settings.AuthAuditOutcomeFailure, settings.AuthAuditCategoryAuth, matched.Username, matched.Username, "invalid or missing TOTP code", ) writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid or missing TOTP code"}) return } } if _, err := app.Sessions.CreateSession(writer, matched.ID, security); err != nil { writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) return } appendAuthAudit( app.ConfigDir, "login", settings.AuthAuditOutcomeSuccess, settings.AuthAuditCategoryAuth, matched.Username, matched.Username, "", ) writeJSON(writer, http.StatusOK, app.buildMeResponse(*matched, settingsPayload)) } func (app *App) logoutHandler(writer http.ResponseWriter, request *http.Request) { actor := actorUsernameFromRequest(request) if app.Sessions != nil { _ = app.Sessions.DestroySession(writer, request) } if actor != "" { appendAuthAudit( app.ConfigDir, "logout", settings.AuthAuditOutcomeSuccess, settings.AuthAuditCategoryAuth, actor, actor, "", ) } writeJSON(writer, http.StatusOK, map[string]bool{"ok": true}) } func (app *App) meHandler(writer http.ResponseWriter, request *http.Request) { user, ok := UserFromContext(request.Context()) if !ok { writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "not authenticated"}) return } settingsPayload, err := loadSettingsOrDefault(app.ConfigDir) if err != nil { writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) return } writeJSON(writer, http.StatusOK, app.buildMeResponse(user, settingsPayload)) } func (app *App) buildMeResponse(user settings.UserCredential, settingsPayload settings.Settings) meResponse { security := effectiveSecurity(settingsPayload.Security) groups := user.GroupNames if groups == nil { groups = []string{} } permissions := permissionList(user, settingsPayload.Groups) if permissions == nil { permissions = []string{} } return meResponse{ UserID: user.ID, Username: user.Username, Groups: groups, TOTPConfirmed: user.TOTPConfirmed, TOTPEnabled: security.TotpEnabled, Permissions: permissions, } } func UserFromContext(ctx context.Context) (settings.UserCredential, bool) { user, ok := ctx.Value(contextUserKey).(settings.UserCredential) return user, ok } func allPermissionList() []string { list := make([]string, 0, len(allowedPermissions)) for permission := range allowedPermissions { list = append(list, permission) } // Stable-ish order for tests: sort manually via known catalog order. ordered := []string{ "nodes.read", "nodes.exec", "nodes.update", "nodes.delete", "jobs.read", "jobs.run", "actions.create", "actions.update", "actions.delete", "users.manage", "secrets.manage", "roles.manage", "logs.read", } _ = list return ordered } func newUserID() (string, error) { return auth.NewRandomID() } func loadSettingsOrDefault(configDir string) (settings.Settings, error) { payload, err := settings.LoadSettings(configDir) if err != nil { if errors.Is(err, os.ErrNotExist) { return settings.Settings{ Groups: []settings.Group{}, Security: settings.DefaultSecuritySettings(), Network: settings.DefaultNetworkSettings(), }, nil } return settings.Settings{}, err } if payload.Groups == nil { payload.Groups = []settings.Group{} } payload.Network = settings.EffectiveNetwork(payload.Network) if migrateLogsReadPermission(&payload) { if err := settings.SaveSettings(configDir, payload); err != nil { return settings.Settings{}, err } } return payload, nil } // migrateLogsReadPermission grants logs.read to groups that previously could // view logs via nodes.read or users.manage. Returns true when settings changed. func migrateLogsReadPermission(payload *settings.Settings) bool { changed := false for index := range payload.Groups { group := &payload.Groups[index] hasLogsRead := false hadLogAccess := false for _, permission := range group.Permissions { switch permission { case "logs.read": hasLogsRead = true case "nodes.read", "users.manage": hadLogAccess = true } } if hasLogsRead || !hadLogAccess { continue } group.Permissions = append(group.Permissions, "logs.read") changed = true } return changed }