Add SSH-managed node registry with connection testing and reauth.

Register hosts under Containers/VMs/Docker with encrypted key storage, and require re-authentication for sensitive account changes.
This commit is contained in:
2026-07-18 16:39:10 +02:00
parent b93b7519ec
commit f4dc8f63d7
31 changed files with 4801 additions and 223 deletions
+255 -4
View File
@@ -1,10 +1,13 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
@@ -24,6 +27,23 @@ type usersResponse struct {
Users []userPublic `json:"users"`
}
type createUserRequest struct {
Username string `json:"username"`
Password string `json:"password"`
GroupNames []string `json:"group_names"`
}
type patchUserRequest struct {
GroupNames *[]string `json:"group_names"`
Password *string `json:"password"`
DisableTOTP *bool `json:"disable_totp"`
}
type deleteUserRequest struct {
CurrentPassword string `json:"current_password"`
TOTPCode string `json:"totp_code"`
}
func (app *App) usersGetHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.requireKey(); err != nil {
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()})
@@ -39,8 +59,206 @@ func (app *App) usersGetHandler(writer http.ResponseWriter, request *http.Reques
writeJSON(writer, http.StatusOK, usersResponse{Users: toPublicUsers(store.Users)})
}
func (app *App) usersCreateHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.authorizeUsersWrite(request); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
if err := app.requireKey(); err != nil {
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()})
return
}
var payload createUserRequest
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 == "" {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "username is required"})
return
}
if err := auth.ValidatePassword(payload.Password, username); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
groupNames, err := validateGroupNames(payload.GroupNames, settingsPayload.Groups)
if err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
store, err := settings.LoadPasswords(app.ConfigDir, app.Key)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
for _, existing := range store.Users {
if strings.EqualFold(existing.Username, username) {
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "username already exists"})
return
}
}
hash, err := auth.HashPassword(payload.Password)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
userID, err := newUserID()
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
now := time.Now().UTC()
store.Users = append(store.Users, settings.UserCredential{
ID: userID,
Username: username,
PasswordHash: hash,
Enabled: true,
GroupNames: groupNames,
CreatedAt: now,
PasswordChangedAt: now,
})
if err := settings.SavePasswords(app.ConfigDir, store, app.Key); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, usersResponse{Users: toPublicUsers(store.Users)})
}
func (app *App) usersPatchHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.authorizeUsersWrite(request); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
if err := app.requireKey(); err != nil {
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()})
return
}
if err := app.requireGraceReauth(request); err != nil {
if err.Error() == reauthRequiredMessage {
writeReauthRequired(writer)
return
}
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: err.Error()})
return
}
userID := strings.TrimSpace(request.URL.Query().Get("id"))
if userID == "" {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "missing user id"})
return
}
var payload patchUserRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
if payload.GroupNames == nil && payload.Password == nil && payload.DisableTOTP == nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "no fields to update"})
return
}
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
store, err := settings.LoadPasswords(app.ConfigDir, app.Key)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
index := -1
for i := range store.Users {
if store.Users[i].ID == userID {
index = i
break
}
}
if index < 0 {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "user not found"})
return
}
target := &store.Users[index]
passwordChanged := false
if payload.GroupNames != nil {
groupNames, err := validateGroupNames(*payload.GroupNames, settingsPayload.Groups)
if err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
wasAdmin := userIsAdministrator(*target)
wouldBeAdmin := false
for _, name := range groupNames {
if name == settings.AdministratorsGroupName {
wouldBeAdmin = true
break
}
}
if wasAdmin && !wouldBeAdmin && countAdministrators(store.Users) <= 1 {
writeJSON(writer, http.StatusConflict, apiErrorResponse{
Error: "cannot remove the last administrator from the Administrators group",
})
return
}
target.GroupNames = groupNames
}
if payload.Password != nil {
if err := auth.ValidatePassword(*payload.Password, target.Username); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
hash, err := auth.HashPassword(*payload.Password)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
target.PasswordHash = hash
target.PasswordChangedAt = time.Now().UTC()
passwordChanged = true
}
if payload.DisableTOTP != nil && *payload.DisableTOTP {
target.TOTPSecret = ""
target.TOTPConfirmed = false
}
if err := settings.SavePasswords(app.ConfigDir, store, app.Key); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
if passwordChanged && app.Sessions != nil {
_ = app.Sessions.InvalidateUserSessions(userID)
}
writeJSON(writer, http.StatusOK, usersResponse{Users: toPublicUsers(store.Users)})
}
func (app *App) usersDeleteHandler(writer http.ResponseWriter, request *http.Request) {
if err := authorizeUsersWrite(request); err != nil {
if err := app.authorizeUsersWrite(request); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
@@ -55,6 +273,17 @@ func (app *App) usersDeleteHandler(writer http.ResponseWriter, request *http.Req
return
}
var payload deleteUserRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
if err := app.requireInlineReauth(request, payload.CurrentPassword, payload.TOTPCode); err != nil {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: err.Error()})
return
}
store, err := settings.LoadPasswords(app.ConfigDir, app.Key)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
@@ -96,9 +325,31 @@ func (app *App) usersDeleteHandler(writer http.ResponseWriter, request *http.Req
writeJSON(writer, http.StatusOK, usersResponse{Users: toPublicUsers(store.Users)})
}
func authorizeUsersWrite(_ *http.Request) error {
// TODO: integrate authz middleware.
return nil
func validateGroupNames(names []string, groups []settings.Group) ([]string, error) {
if names == nil {
return []string{}, nil
}
known := make(map[string]struct{}, len(groups))
for _, group := range groups {
known[group.Name] = struct{}{}
}
result := make([]string, 0, len(names))
seen := make(map[string]struct{}, len(names))
for _, name := range names {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
continue
}
if _, ok := known[trimmed]; !ok {
return nil, fmt.Errorf("unknown group: %s", trimmed)
}
if _, ok := seen[trimmed]; ok {
continue
}
seen[trimmed] = struct{}{}
result = append(result, trimmed)
}
return result, nil
}
func toPublicUsers(users []settings.UserCredential) []userPublic {