From f4dc8f63d7b3792961168d9432241d2eab7b1eaf Mon Sep 17 00:00:00 2001 From: squid Date: Sat, 18 Jul 2026 16:39:10 +0200 Subject: [PATCH] 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. --- service/internal/api/authz.go | 97 ++ service/internal/api/groups_handlers.go | 6 +- service/internal/api/groups_handlers_test.go | 28 +- service/internal/api/me_handlers.go | 323 ++++ service/internal/api/middleware.go | 2 +- service/internal/api/nodes_handlers.go | 460 ++++++ service/internal/api/nodes_handlers_test.go | 344 ++++ service/internal/api/reauth.go | 88 + service/internal/api/router.go | 13 + service/internal/api/setup_auth_handlers.go | 46 +- service/internal/api/test_helpers.go | 13 +- service/internal/api/users_handlers.go | 259 ++- service/internal/api/users_handlers_test.go | 280 +++- service/internal/auth/session.go | 38 +- service/internal/auth/ssh_connect.go | 73 + service/internal/auth/ssh_connect_test.go | 19 + service/internal/auth/sshkey.go | 145 ++ service/internal/auth/sshkey_test.go | 63 + service/internal/auth/uuid.go | 24 + service/internal/settings/nodes.go | 93 ++ service/internal/settings/nodes_test.go | 73 + service/internal/settings/path.go | 2 + service/internal/settings/types.go | 53 +- webui/src/App.css | 255 ++- webui/src/App.test.tsx | 101 +- webui/src/App.tsx | 1524 ++++++++++++++++-- webui/src/ReauthModal.tsx | 96 ++ webui/src/api/client.test.ts | 106 +- webui/src/api/client.ts | 293 +++- webui/src/navigation.test.ts | 20 +- webui/src/navigation.ts | 87 +- 31 files changed, 4801 insertions(+), 223 deletions(-) create mode 100644 service/internal/api/authz.go create mode 100644 service/internal/api/me_handlers.go create mode 100644 service/internal/api/nodes_handlers.go create mode 100644 service/internal/api/nodes_handlers_test.go create mode 100644 service/internal/api/reauth.go create mode 100644 service/internal/auth/ssh_connect.go create mode 100644 service/internal/auth/ssh_connect_test.go create mode 100644 service/internal/auth/sshkey.go create mode 100644 service/internal/auth/sshkey_test.go create mode 100644 service/internal/auth/uuid.go create mode 100644 service/internal/settings/nodes.go create mode 100644 service/internal/settings/nodes_test.go create mode 100644 webui/src/ReauthModal.tsx diff --git a/service/internal/api/authz.go b/service/internal/api/authz.go new file mode 100644 index 0000000..2869ffc --- /dev/null +++ b/service/internal/api/authz.go @@ -0,0 +1,97 @@ +package api + +import ( + "errors" + "net/http" + + "codeberg.org/SquidSE/ClusterCanvas/service/internal/settings" +) + +var errUsersManageRequired = errors.New("permission users.manage required") +var errNodesReadRequired = errors.New("permission nodes.read required") +var errNodesExecRequired = errors.New("permission nodes.exec required") + +// permissionsForUser returns the union of permissions from the user's groups. +func permissionsForUser(user settings.UserCredential, groups []settings.Group) map[string]struct{} { + groupByName := make(map[string]settings.Group, len(groups)) + for _, group := range groups { + groupByName[group.Name] = group + } + + result := make(map[string]struct{}) + for _, groupName := range user.GroupNames { + group, ok := groupByName[groupName] + if !ok { + continue + } + for _, permission := range group.Permissions { + result[permission] = struct{}{} + } + } + return result +} + +func userHasPermission(user settings.UserCredential, groups []settings.Group, permission string) bool { + _, ok := permissionsForUser(user, groups)[permission] + return ok +} + +func permissionList(user settings.UserCredential, groups []settings.Group) []string { + set := permissionsForUser(user, groups) + ordered := allPermissionList() + result := make([]string, 0, len(set)) + for _, permission := range ordered { + if _, ok := set[permission]; ok { + result = append(result, permission) + } + } + return result +} + +func (app *App) authorizeUsersWrite(request *http.Request) error { + user, ok := UserFromContext(request.Context()) + if !ok { + return errors.New("authentication required") + } + + payload, err := loadSettingsOrDefault(app.ConfigDir) + if err != nil { + return err + } + if !userHasPermission(user, payload.Groups, "users.manage") { + return errUsersManageRequired + } + return nil +} + +func (app *App) authorizeNodesRead(request *http.Request) error { + user, ok := UserFromContext(request.Context()) + if !ok { + return errors.New("authentication required") + } + + payload, err := loadSettingsOrDefault(app.ConfigDir) + if err != nil { + return err + } + if !userHasPermission(user, payload.Groups, "nodes.read") { + return errNodesReadRequired + } + return nil +} + +func (app *App) authorizeNodesExec(request *http.Request) error { + user, ok := UserFromContext(request.Context()) + if !ok { + return errors.New("authentication required") + } + + payload, err := loadSettingsOrDefault(app.ConfigDir) + if err != nil { + return err + } + if !userHasPermission(user, payload.Groups, "nodes.exec") { + return errNodesExecRequired + } + return nil +} diff --git a/service/internal/api/groups_handlers.go b/service/internal/api/groups_handlers.go index 03a1193..256e79b 100644 --- a/service/internal/api/groups_handlers.go +++ b/service/internal/api/groups_handlers.go @@ -158,10 +158,8 @@ func validateGroup(group settings.Group) error { return errors.New("group.scope_name is required") } - if len(group.Permissions) == 0 { - return errors.New("group.permissions must be non-empty") - } - + // Empty permissions are allowed so a newly scoped node group can be + // created first and roles assigned later in Configuration → Groups. for _, permission := range group.Permissions { if strings.TrimSpace(permission) == "" { return errors.New("permission strings must be non-empty") diff --git a/service/internal/api/groups_handlers_test.go b/service/internal/api/groups_handlers_test.go index d9c67c2..40001b5 100644 --- a/service/internal/api/groups_handlers_test.go +++ b/service/internal/api/groups_handlers_test.go @@ -18,7 +18,7 @@ type apiErrorResponseTest struct { Error string `json:"error"` } -func TestGroupsGetEmptyWhenNoGroups(t *testing.T) { +func TestGroupsGetIncludesAdministratorsFromSetup(t *testing.T) { configDir := t.TempDir() cookie := seedCompletedSetup(t, configDir) router := NewRouter(configDir) @@ -36,8 +36,8 @@ func TestGroupsGetEmptyWhenNoGroups(t *testing.T) { t.Fatalf("decode response: %v", err) } - if payload.Groups == nil || len(payload.Groups) != 0 { - t.Fatalf("expected empty groups list, got %#v", payload.Groups) + if len(payload.Groups) != 1 || payload.Groups[0].Name != settings.AdministratorsGroupName { + t.Fatalf("expected Administrators group from seed, got %#v", payload.Groups) } } @@ -118,14 +118,20 @@ func TestGroupsUpsertUpsertsByName(t *testing.T) { if err := json.NewDecoder(getRec.Body).Decode(&getPayload); err != nil { t.Fatalf("decode response: %v", err) } - if len(getPayload.Groups) != 1 { - t.Fatalf("expected 1 group, got %d", len(getPayload.Groups)) + if len(getPayload.Groups) != 2 { + t.Fatalf("expected 2 groups (Administrators + Admins), got %d", len(getPayload.Groups)) } - if getPayload.Groups[0].Name != "Admins" { - t.Fatalf("expected group name %q, got %q", "Admins", getPayload.Groups[0].Name) + var admins *settings.Group + for index := range getPayload.Groups { + if getPayload.Groups[index].Name == "Admins" { + admins = &getPayload.Groups[index] + } } - if getPayload.Groups[0].ScopeName != "node-2" { - t.Fatalf("expected scope_name %q, got %q", "node-2", getPayload.Groups[0].ScopeName) + if admins == nil { + t.Fatal("expected Admins group") + } + if admins.ScopeName != "node-2" { + t.Fatalf("expected scope_name %q, got %q", "node-2", admins.ScopeName) } } @@ -167,8 +173,8 @@ func TestGroupsDeleteRemovesByName(t *testing.T) { if err := json.NewDecoder(getRec.Body).Decode(&getPayload); err != nil { t.Fatalf("decode response: %v", err) } - if len(getPayload.Groups) != 0 { - t.Fatalf("expected empty groups, got %#v", getPayload.Groups) + if len(getPayload.Groups) != 1 || getPayload.Groups[0].Name != settings.AdministratorsGroupName { + t.Fatalf("expected only Administrators remaining, got %#v", getPayload.Groups) } } diff --git a/service/internal/api/me_handlers.go b/service/internal/api/me_handlers.go new file mode 100644 index 0000000..137cfd9 --- /dev/null +++ b/service/internal/api/me_handlers.go @@ -0,0 +1,323 @@ +package api + +import ( + "encoding/json" + "net/http" + "strings" + "time" + + "codeberg.org/SquidSE/ClusterCanvas/service/internal/auth" + "codeberg.org/SquidSE/ClusterCanvas/service/internal/settings" +) + +type changePasswordRequest struct { + CurrentPassword string `json:"current_password"` + NewPassword string `json:"new_password"` + TOTPCode string `json:"totp_code"` +} + +type totpBeginRequest struct { + CurrentPassword string `json:"current_password"` + TOTPCode string `json:"totp_code"` +} + +type totpConfirmRequest struct { + Secret string `json:"secret"` + Code string `json:"code"` + CurrentPassword string `json:"current_password"` + TOTPCode string `json:"totp_code"` +} + +type totpDisableRequest struct { + CurrentPassword string `json:"current_password"` + TOTPCode string `json:"totp_code"` +} + +func (app *App) reauthHandler(writer http.ResponseWriter, request *http.Request) { + user, ok := UserFromContext(request.Context()) + if !ok { + writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "not authenticated"}) + return + } + session, ok := SessionFromContext(request.Context()) + if !ok { + writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "not authenticated"}) + return + } + + var payload reauthRequest + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"}) + return + } + + settingsPayload, err := loadSettingsOrDefault(app.ConfigDir) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + security := effectiveSecurity(settingsPayload.Security) + + if err := verifyActorCredentials(user, payload.Password, payload.TOTPCode, security); err != nil { + writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: err.Error()}) + return + } + + if app.Sessions == nil { + writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: "sessions unavailable"}) + return + } + if err := app.Sessions.MarkReauth(session.ID); err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + + writeJSON(writer, http.StatusOK, map[string]bool{"ok": true}) +} + +func (app *App) mePasswordHandler(writer http.ResponseWriter, request *http.Request) { + user, ok := UserFromContext(request.Context()) + if !ok { + writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "not authenticated"}) + return + } + if err := app.requireKey(); err != nil { + writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()}) + return + } + + var payload changePasswordRequest + 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 + } + + if err := auth.ValidatePassword(payload.NewPassword, user.Username); err != nil { + writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()}) + return + } + + hash, err := auth.HashPassword(payload.NewPassword) + 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 + } + + found := false + now := time.Now().UTC() + for index := range store.Users { + if store.Users[index].ID != user.ID { + continue + } + store.Users[index].PasswordHash = hash + store.Users[index].PasswordChangedAt = now + found = true + break + } + if !found { + writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "user not found"}) + return + } + + if err := settings.SavePasswords(app.ConfigDir, store, app.Key); err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + + if app.Sessions != nil { + _ = app.Sessions.InvalidateUserSessions(user.ID) + _ = app.Sessions.DestroySession(writer, request) + } + + writeJSON(writer, http.StatusOK, map[string]bool{"ok": true}) +} + +func (app *App) meTOTPBeginHandler(writer http.ResponseWriter, request *http.Request) { + user, ok := UserFromContext(request.Context()) + if !ok { + writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "not authenticated"}) + return + } + + var payload totpBeginRequest + 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 + } + + settingsPayload, err := loadSettingsOrDefault(app.ConfigDir) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + security := effectiveSecurity(settingsPayload.Security) + if !security.TotpEnabled { + writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "TOTP is not enabled on this server"}) + return + } + if user.TOTPConfirmed && user.TOTPSecret != "" { + writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "TOTP is already enabled"}) + return + } + + key, err := auth.GenerateTOTPSecret(user.Username) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + + writeJSON(writer, http.StatusOK, setupTOTPBeginResponse{ + Secret: key.Secret(), + OTPAuthURL: key.URL(), + }) +} + +func (app *App) meTOTPConfirmHandler(writer http.ResponseWriter, request *http.Request) { + user, ok := UserFromContext(request.Context()) + if !ok { + writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "not authenticated"}) + return + } + if err := app.requireKey(); err != nil { + writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()}) + return + } + + var payload totpConfirmRequest + 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 + } + + settingsPayload, err := loadSettingsOrDefault(app.ConfigDir) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + security := effectiveSecurity(settingsPayload.Security) + if !security.TotpEnabled { + writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "TOTP is not enabled on this server"}) + return + } + + secret := strings.TrimSpace(payload.Secret) + if secret == "" { + writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "missing secret"}) + return + } + if !auth.VerifyTOTPCode(secret, payload.Code) { + writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid TOTP code"}) + return + } + + store, err := settings.LoadPasswords(app.ConfigDir, app.Key) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + + found := false + for index := range store.Users { + if store.Users[index].ID != user.ID { + continue + } + store.Users[index].TOTPSecret = secret + store.Users[index].TOTPConfirmed = true + found = true + break + } + if !found { + writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "user not found"}) + return + } + + if err := settings.SavePasswords(app.ConfigDir, store, app.Key); err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + + writeJSON(writer, http.StatusOK, map[string]bool{"ok": true}) +} + +func (app *App) meTOTPDisableHandler(writer http.ResponseWriter, request *http.Request) { + user, ok := UserFromContext(request.Context()) + if !ok { + writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "not authenticated"}) + return + } + if err := app.requireKey(); err != nil { + writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()}) + return + } + + var payload totpDisableRequest + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"}) + return + } + + if !user.TOTPConfirmed || user.TOTPSecret == "" { + writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "TOTP is not enabled"}) + return + } + + // Always require password; require current TOTP code when enrolled. + okPassword, err := auth.VerifyPassword(payload.CurrentPassword, user.PasswordHash) + if err != nil || !okPassword { + writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid credentials"}) + return + } + if !auth.VerifyTOTPCode(user.TOTPSecret, payload.TOTPCode) { + writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid or missing TOTP code"}) + return + } + + store, err := settings.LoadPasswords(app.ConfigDir, app.Key) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + + found := false + for index := range store.Users { + if store.Users[index].ID != user.ID { + continue + } + store.Users[index].TOTPSecret = "" + store.Users[index].TOTPConfirmed = false + found = true + break + } + if !found { + writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "user not found"}) + return + } + + if err := settings.SavePasswords(app.ConfigDir, store, app.Key); err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + + writeJSON(writer, http.StatusOK, map[string]bool{"ok": true}) +} diff --git a/service/internal/api/middleware.go b/service/internal/api/middleware.go index d1685a8..ef6f7f8 100644 --- a/service/internal/api/middleware.go +++ b/service/internal/api/middleware.go @@ -136,7 +136,7 @@ func (app *App) withCORS(next http.Handler) http.Handler { writer.Header().Set("Access-Control-Allow-Credentials", "true") writer.Header().Set("Vary", "Origin") } - writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") writer.Header().Set("Access-Control-Allow-Headers", "Content-Type") if request.Method == http.MethodOptions { diff --git a/service/internal/api/nodes_handlers.go b/service/internal/api/nodes_handlers.go new file mode 100644 index 0000000..b69baff --- /dev/null +++ b/service/internal/api/nodes_handlers.go @@ -0,0 +1,460 @@ +package api + +import ( + "encoding/json" + "errors" + "net" + "net/http" + "strings" + "time" + + "codeberg.org/SquidSE/ClusterCanvas/service/internal/auth" + "codeberg.org/SquidSE/ClusterCanvas/service/internal/settings" +) + +type nodesResponse struct { + Nodes []settings.Node `json:"nodes"` +} + +type nodeResponse struct { + Node settings.Node `json:"node"` +} + +type createNodeGenerate struct { + Algorithm string `json:"algorithm"` + RSABits int `json:"rsa_bits"` + KDFRounds int `json:"kdf_rounds"` +} + +type createNodeNewGroup struct { + Name string `json:"name"` +} + +type createNodeRequest struct { + ID string `json:"id"` + Kind settings.NodeKind `json:"kind"` + Name string `json:"name"` + HostIP string `json:"host_ip"` + Username string `json:"username"` + GroupName string `json:"group_name"` + NewGroup *createNodeNewGroup `json:"new_group"` + Generate *createNodeGenerate `json:"generate"` + PrivateKey string `json:"private_key"` +} + +func (app *App) nodesListHandler(writer http.ResponseWriter, request *http.Request) { + if err := app.authorizeNodesRead(request); err != nil { + writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()}) + return + } + + user, ok := UserFromContext(request.Context()) + if !ok { + writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "authentication required"}) + return + } + + settingsPayload, err := loadSettingsOrDefault(app.ConfigDir) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + + store, err := settings.LoadNodesOrEmpty(app.ConfigDir) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + + kindFilter := strings.TrimSpace(request.URL.Query().Get("kind")) + filtered := make([]settings.Node, 0, len(store.Nodes)) + for _, node := range store.Nodes { + if kindFilter != "" && string(node.Kind) != kindFilter { + continue + } + if !userCanAccessNode(user, settingsPayload.Groups, node) { + continue + } + filtered = append(filtered, node) + } + + writeJSON(writer, http.StatusOK, nodesResponse{Nodes: filtered}) +} + +func (app *App) nodesGetHandler(writer http.ResponseWriter, request *http.Request) { + if err := app.authorizeNodesRead(request); err != nil { + writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()}) + return + } + + user, ok := UserFromContext(request.Context()) + if !ok { + writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "authentication required"}) + return + } + + nodeID := strings.TrimSpace(request.PathValue("id")) + if nodeID == "" { + writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "missing node id"}) + return + } + + settingsPayload, err := loadSettingsOrDefault(app.ConfigDir) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + + store, err := settings.LoadNodesOrEmpty(app.ConfigDir) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + + for _, node := range store.Nodes { + if node.ID != nodeID { + continue + } + if !userCanAccessNode(user, settingsPayload.Groups, node) { + writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: "permission denied"}) + return + } + writeJSON(writer, http.StatusOK, nodeResponse{Node: node}) + return + } + + writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "node not found"}) +} + +type nodeSSHTestResponse struct { + OK bool `json:"ok"` + Message string `json:"message"` +} + +func (app *App) nodesTestSSHHandler(writer http.ResponseWriter, request *http.Request) { + if err := app.authorizeNodesExec(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 + } + + user, ok := UserFromContext(request.Context()) + if !ok { + writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "authentication required"}) + return + } + + nodeID := strings.TrimSpace(request.PathValue("id")) + if nodeID == "" { + writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "missing node id"}) + return + } + + settingsPayload, err := loadSettingsOrDefault(app.ConfigDir) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + + store, err := settings.LoadNodesOrEmpty(app.ConfigDir) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + + var node settings.Node + found := false + for _, candidate := range store.Nodes { + if candidate.ID != nodeID { + continue + } + node = candidate + found = true + break + } + if !found { + writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "node not found"}) + return + } + if !userCanAccessNode(user, settingsPayload.Groups, node) { + writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: "permission denied"}) + return + } + + keyStore, err := settings.LoadNodeKeysOrEmpty(app.ConfigDir, app.Key) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + + var keyEntry settings.NodeKeyEntry + keyFound := false + for _, entry := range keyStore.Keys { + if entry.NodeID == node.ID { + keyEntry = entry + keyFound = true + break + } + } + if !keyFound { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{ + Error: "private key not found for node", + }) + return + } + + if err := auth.TestSSHConnection(node.HostIP, node.Username, keyEntry.PrivateKey, keyEntry.Passphrase); err != nil { + writeJSON(writer, http.StatusOK, nodeSSHTestResponse{ + OK: false, + Message: err.Error(), + }) + return + } + + writeJSON(writer, http.StatusOK, nodeSSHTestResponse{ + OK: true, + Message: "SSH connection succeeded", + }) +} + +func (app *App) nodesCreateHandler(writer http.ResponseWriter, request *http.Request) { + if err := app.authorizeNodesExec(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 createNodeRequest + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"}) + return + } + + node, keyEntry, err := buildNodeFromCreateRequest(payload) + if 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 + } + + if payload.NewGroup != nil { + groupName := strings.TrimSpace(payload.NewGroup.Name) + if groupName == "" { + writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "new_group.name is required"}) + return + } + for _, existing := range settingsPayload.Groups { + if existing.Name == groupName { + writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "group already exists"}) + return + } + } + newGroup := settings.Group{ + Name: groupName, + ScopeKind: settings.GroupScopeNode, + ScopeName: node.ID, + Permissions: []string{}, + } + settingsPayload.Groups = append(settingsPayload.Groups, newGroup) + node.GroupName = groupName + } else { + groupName := strings.TrimSpace(payload.GroupName) + if groupName == "" { + writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "group_name is required"}) + return + } + found := false + for _, existing := range settingsPayload.Groups { + if existing.Name == groupName { + found = true + break + } + } + if !found { + writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "group not found"}) + return + } + node.GroupName = groupName + } + + store, err := settings.LoadNodesOrEmpty(app.ConfigDir) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + for _, existing := range store.Nodes { + if existing.ID == node.ID { + writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "node id already exists"}) + return + } + if existing.Name == node.Name && existing.Kind == node.Kind { + writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "node name already exists for this kind"}) + return + } + } + + keyStore, err := settings.LoadNodeKeysOrEmpty(app.ConfigDir, app.Key) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + + store.Nodes = append(store.Nodes, node) + keyStore.Keys = append(keyStore.Keys, keyEntry) + + if payload.NewGroup != nil { + if err := settings.SaveSettings(app.ConfigDir, settingsPayload); err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + } + if err := settings.SaveNodes(app.ConfigDir, store); err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + if err := settings.SaveNodeKeys(app.ConfigDir, keyStore, app.Key); err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + + writeJSON(writer, http.StatusCreated, nodeResponse{Node: node}) +} + +func buildNodeFromCreateRequest(payload createNodeRequest) (settings.Node, settings.NodeKeyEntry, error) { + kind := payload.Kind + switch kind { + case settings.NodeKindContainer, settings.NodeKindVM, settings.NodeKindDocker: + // ok + default: + return settings.Node{}, settings.NodeKeyEntry{}, errors.New("kind must be container, vm, or docker") + } + + name := strings.TrimSpace(payload.Name) + if name == "" { + return settings.Node{}, settings.NodeKeyEntry{}, errors.New("name is required") + } + + hostIP := strings.TrimSpace(payload.HostIP) + if hostIP == "" { + return settings.Node{}, settings.NodeKeyEntry{}, errors.New("host_ip is required") + } + if parsed := net.ParseIP(hostIP); parsed == nil { + return settings.Node{}, settings.NodeKeyEntry{}, errors.New("host_ip must be a valid IPv4 or IPv6 address") + } + + username := strings.TrimSpace(payload.Username) + if username == "" { + username = auth.DefaultSSHUsername + } + + nodeID := strings.TrimSpace(payload.ID) + if nodeID == "" { + generatedID, err := auth.NewUUID() + if err != nil { + return settings.Node{}, settings.NodeKeyEntry{}, err + } + nodeID = generatedID + } else if !isUUID(nodeID) { + return settings.Node{}, settings.NodeKeyEntry{}, errors.New("id must be a UUID") + } + + var generated auth.GeneratedSSHKey + var err error + switch { + case payload.Generate != nil && strings.TrimSpace(payload.PrivateKey) != "": + return settings.Node{}, settings.NodeKeyEntry{}, errors.New("provide either generate or private_key, not both") + case payload.Generate != nil: + generated, err = auth.GenerateSSHKey( + payload.Generate.Algorithm, + payload.Generate.RSABits, + payload.Generate.KDFRounds, + ) + case strings.TrimSpace(payload.PrivateKey) != "": + generated, err = auth.ParseSSHPrivateKey(payload.PrivateKey) + default: + return settings.Node{}, settings.NodeKeyEntry{}, errors.New("generate or private_key is required") + } + if err != nil { + return settings.Node{}, settings.NodeKeyEntry{}, err + } + + now := time.Now().UTC() + node := settings.Node{ + ID: nodeID, + Kind: kind, + Name: name, + HostIP: hostIP, + Username: username, + PublicKey: generated.PublicKey, + KeyAlgo: generated.Algorithm, + CreatedAt: now, + } + keyEntry := settings.NodeKeyEntry{ + NodeID: nodeID, + PrivateKey: generated.PrivateKeyPEM, + Algorithm: generated.Algorithm, + RSABits: generated.RSABits, + KDFRounds: generated.KDFRounds, + } + return node, keyEntry, nil +} + +func userCanAccessNode(user settings.UserCredential, groups []settings.Group, node settings.Node) bool { + for _, groupName := range user.GroupNames { + if groupName == settings.AdministratorsGroupName { + return true + } + } + for _, groupName := range user.GroupNames { + if groupName == node.GroupName { + return true + } + } + // Users with nodes.read via any group can list nodes they belong to above; + // Administrators already returned. Also allow if any of user's groups has + // scope_kind=node and scope_name matching this node id. + groupByName := make(map[string]settings.Group, len(groups)) + for _, group := range groups { + groupByName[group.Name] = group + } + for _, groupName := range user.GroupNames { + group, ok := groupByName[groupName] + if !ok { + continue + } + if group.ScopeKind == settings.GroupScopeNode && group.ScopeName == node.ID { + return true + } + } + return false +} + +func isUUID(value string) bool { + if len(value) != 36 { + return false + } + for index, char := range value { + switch index { + case 8, 13, 18, 23: + if char != '-' { + return false + } + default: + if (char < '0' || char > '9') && (char < 'a' || char > 'f') && (char < 'A' || char > 'F') { + return false + } + } + } + return true +} diff --git a/service/internal/api/nodes_handlers_test.go b/service/internal/api/nodes_handlers_test.go new file mode 100644 index 0000000..335244c --- /dev/null +++ b/service/internal/api/nodes_handlers_test.go @@ -0,0 +1,344 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "codeberg.org/SquidSE/ClusterCanvas/service/internal/auth" + "codeberg.org/SquidSE/ClusterCanvas/service/internal/settings" +) + +func TestNodesCreateGeneratesEd25519AndStoresEncryptedKey(t *testing.T) { + configDir := t.TempDir() + cookie := seedCompletedSetup(t, configDir) + router := NewRouter(configDir) + + body := map[string]any{ + "id": "11111111-2222-4333-8444-555555555555", + "kind": "container", + "name": "ct-alpha", + "host_ip": "192.168.10.20", + "username": "clustercanvas", + "group_name": settings.AdministratorsGroupName, + "generate": map[string]any{ + "algorithm": "ed25519", + "kdf_rounds": 100, + }, + } + payload, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + request := withSession( + httptest.NewRequest(http.MethodPost, "/api/v1/nodes", bytes.NewReader(payload)), + cookie, + ) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusCreated { + t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String()) + } + + var response nodeResponse + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatalf("decode: %v", err) + } + if response.Node.ID != "11111111-2222-4333-8444-555555555555" { + t.Fatalf("id = %q", response.Node.ID) + } + if response.Node.PublicKey == "" { + t.Fatal("expected public key") + } + if strings.Contains(recorder.Body.String(), "BEGIN OPENSSH PRIVATE KEY") { + t.Fatal("private key must not appear in API response") + } + + keyBytes, err := settings.KeyFromEnv() + if err != nil { + t.Fatalf("KeyFromEnv: %v", err) + } + keyStore, err := settings.LoadNodeKeys(configDir, keyBytes) + if err != nil { + t.Fatalf("LoadNodeKeys: %v", err) + } + if len(keyStore.Keys) != 1 { + t.Fatalf("keys = %d", len(keyStore.Keys)) + } + if keyStore.Keys[0].NodeID != response.Node.ID { + t.Fatalf("key node id = %q", keyStore.Keys[0].NodeID) + } + if !strings.Contains(keyStore.Keys[0].PrivateKey, "BEGIN OPENSSH PRIVATE KEY") { + t.Fatal("expected openssh private key in encrypted store") + } + if keyStore.Keys[0].KDFRounds != 100 { + t.Fatalf("kdf rounds = %d", keyStore.Keys[0].KDFRounds) + } +} + +func TestNodesCreateRejectsWithoutNodesExec(t *testing.T) { + configDir := t.TempDir() + _ = seedCompletedSetup(t, configDir) + + keyBytes, err := settings.KeyFromEnv() + if err != nil { + t.Fatalf("KeyFromEnv: %v", err) + } + now := time.Now().UTC() + passwordHash, err := auth.HashPassword("correct horse battery staple extra") + if err != nil { + t.Fatalf("HashPassword: %v", err) + } + + payload := settings.Settings{ + SetupCompleted: true, + Groups: []settings.Group{ + { + Name: settings.AdministratorsGroupName, + ScopeKind: settings.GroupScopeGroup, + ScopeName: "Admin Group", + Permissions: allPermissionList(), + }, + { + Name: "Readers", + ScopeKind: settings.GroupScopeGroup, + ScopeName: "Readers", + Permissions: []string{"nodes.read"}, + }, + }, + Security: settings.DefaultSecuritySettings(), + Network: settings.DefaultNetworkSettings(), + } + if err := settings.SaveSettings(configDir, payload); err != nil { + t.Fatalf("SaveSettings: %v", err) + } + if err := settings.SavePasswords(configDir, settings.PasswordStore{ + Users: []settings.UserCredential{ + { + ID: "admin-id", + Username: "Admin", + PasswordHash: passwordHash, + Enabled: true, + GroupNames: []string{settings.AdministratorsGroupName}, + CreatedAt: now, + PasswordChangedAt: now, + }, + { + ID: "reader-id", + Username: "reader", + PasswordHash: passwordHash, + Enabled: true, + GroupNames: []string{"Readers"}, + CreatedAt: now, + PasswordChangedAt: now, + }, + }, + }, keyBytes); err != nil { + t.Fatalf("SavePasswords: %v", err) + } + + router := NewRouter(configDir) + loginRecorder := httptest.NewRecorder() + loginRequest := httptest.NewRequest( + http.MethodPost, + "/api/v1/auth/login", + bytes.NewReader([]byte(`{"username":"reader","password":"correct horse battery staple extra"}`)), + ) + router.ServeHTTP(loginRecorder, loginRequest) + if loginRecorder.Code != http.StatusOK { + t.Fatalf("login status = %d body=%s", loginRecorder.Code, loginRecorder.Body.String()) + } + var readerCookie *http.Cookie + for _, candidate := range loginRecorder.Result().Cookies() { + if candidate.Name == auth.SessionCookieName { + readerCookie = candidate + } + } + if readerCookie == nil { + t.Fatal("missing session cookie") + } + + createBody := []byte(`{ + "kind":"container", + "name":"ct-beta", + "host_ip":"10.0.0.2", + "group_name":"Readers", + "generate":{"algorithm":"ed25519"} + }`) + request := withSession( + httptest.NewRequest(http.MethodPost, "/api/v1/nodes", bytes.NewReader(createBody)), + readerCookie, + ) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + if recorder.Code != http.StatusForbidden { + t.Fatalf("status = %d body=%s", recorder.Code, recorder.Body.String()) + } +} + +func TestNodesCreateRSAAndListByKind(t *testing.T) { + configDir := t.TempDir() + cookie := seedCompletedSetup(t, configDir) + router := NewRouter(configDir) + + body := []byte(`{ + "kind":"vm", + "name":"vm-one", + "host_ip":"10.0.0.8", + "group_name":"Administrators", + "generate":{"algorithm":"rsa","rsa_bits":2048} + }`) + request := withSession( + httptest.NewRequest(http.MethodPost, "/api/v1/nodes", bytes.NewReader(body)), + cookie, + ) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + if recorder.Code != http.StatusCreated { + t.Fatalf("create status = %d body=%s", recorder.Code, recorder.Body.String()) + } + + listRequest := withSession( + httptest.NewRequest(http.MethodGet, "/api/v1/nodes?kind=vm", nil), + cookie, + ) + listRecorder := httptest.NewRecorder() + router.ServeHTTP(listRecorder, listRequest) + if listRecorder.Code != http.StatusOK { + t.Fatalf("list status = %d", listRecorder.Code) + } + var list nodesResponse + if err := json.Unmarshal(listRecorder.Body.Bytes(), &list); err != nil { + t.Fatalf("decode: %v", err) + } + if len(list.Nodes) != 1 || list.Nodes[0].Name != "vm-one" { + t.Fatalf("nodes = %+v", list.Nodes) + } + if list.Nodes[0].KeyAlgo != "rsa" { + t.Fatalf("key algo = %q", list.Nodes[0].KeyAlgo) + } + + emptyRequest := withSession( + httptest.NewRequest(http.MethodGet, "/api/v1/nodes?kind=docker", nil), + cookie, + ) + emptyRecorder := httptest.NewRecorder() + router.ServeHTTP(emptyRecorder, emptyRequest) + var emptyList nodesResponse + if err := json.Unmarshal(emptyRecorder.Body.Bytes(), &emptyList); err != nil { + t.Fatalf("decode: %v", err) + } + if len(emptyList.Nodes) != 0 { + t.Fatalf("expected no docker nodes, got %d", len(emptyList.Nodes)) + } +} + +func TestNodesCreateWithNewGroup(t *testing.T) { + configDir := t.TempDir() + cookie := seedCompletedSetup(t, configDir) + router := NewRouter(configDir) + + body := []byte(`{ + "kind":"docker", + "name":"docker-host", + "host_ip":"10.1.1.1", + "new_group":{"name":"Docker Hosts"}, + "generate":{"algorithm":"ed25519","kdf_rounds":64} + }`) + request := withSession( + httptest.NewRequest(http.MethodPost, "/api/v1/nodes", bytes.NewReader(body)), + cookie, + ) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + if recorder.Code != http.StatusCreated { + t.Fatalf("status = %d body=%s", recorder.Code, recorder.Body.String()) + } + + var response nodeResponse + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatalf("decode: %v", err) + } + if response.Node.GroupName != "Docker Hosts" { + t.Fatalf("group = %q", response.Node.GroupName) + } + + settingsPayload, err := settings.LoadSettings(configDir) + if err != nil { + t.Fatalf("LoadSettings: %v", err) + } + found := false + for _, group := range settingsPayload.Groups { + if group.Name == "Docker Hosts" { + found = true + if group.ScopeKind != settings.GroupScopeNode { + t.Fatalf("scope kind = %q", group.ScopeKind) + } + if group.ScopeName != response.Node.ID { + t.Fatalf("scope name = %q want %q", group.ScopeName, response.Node.ID) + } + if len(group.Permissions) != 0 { + t.Fatalf("expected empty permissions, got %v", group.Permissions) + } + } + } + if !found { + t.Fatal("new group not saved") + } +} + +func TestNodesTestSSHReportsFailureForUnreachableHost(t *testing.T) { + configDir := t.TempDir() + cookie := seedCompletedSetup(t, configDir) + router := NewRouter(configDir) + + createBody := []byte(`{ + "id":"aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", + "kind":"container", + "name":"unreachable", + "host_ip":"127.0.0.1", + "group_name":"Administrators", + "generate":{"algorithm":"ed25519"} + }`) + createRequest := withSession( + httptest.NewRequest(http.MethodPost, "/api/v1/nodes", bytes.NewReader(createBody)), + cookie, + ) + createRecorder := httptest.NewRecorder() + router.ServeHTTP(createRecorder, createRequest) + if createRecorder.Code != http.StatusCreated { + t.Fatalf("create status = %d body=%s", createRecorder.Code, createRecorder.Body.String()) + } + + // 127.0.0.1 with no SSH listener (or auth failure) should not panic; expect ok=false. + testRequest := withSession( + httptest.NewRequest( + http.MethodPost, + "/api/v1/nodes/aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee/test-ssh", + nil, + ), + cookie, + ) + testRecorder := httptest.NewRecorder() + router.ServeHTTP(testRecorder, testRequest) + if testRecorder.Code != http.StatusOK { + t.Fatalf("test status = %d body=%s", testRecorder.Code, testRecorder.Body.String()) + } + + var response nodeSSHTestResponse + if err := json.Unmarshal(testRecorder.Body.Bytes(), &response); err != nil { + t.Fatalf("decode: %v", err) + } + if response.OK { + t.Fatal("expected SSH test to fail against localhost without matching key/auth") + } + if response.Message == "" { + t.Fatal("expected failure message") + } +} diff --git a/service/internal/api/reauth.go b/service/internal/api/reauth.go new file mode 100644 index 0000000..c993424 --- /dev/null +++ b/service/internal/api/reauth.go @@ -0,0 +1,88 @@ +package api + +import ( + "context" + "errors" + "net/http" + "time" + + "codeberg.org/SquidSE/ClusterCanvas/service/internal/auth" + "codeberg.org/SquidSE/ClusterCanvas/service/internal/settings" +) + +const reauthRequiredMessage = "reauth_required" + +type reauthRequest struct { + Password string `json:"password"` + TOTPCode string `json:"totp_code"` +} + +func SessionFromContext(ctx context.Context) (settings.SessionRecord, bool) { + session, ok := ctx.Value(contextSessionKey).(settings.SessionRecord) + return session, ok +} + +func verifyActorCredentials(user settings.UserCredential, password string, totpCode string, security settings.SecuritySettings) error { + ok, err := auth.VerifyPassword(password, user.PasswordHash) + if err != nil || !ok { + return errors.New("invalid credentials") + } + + requiresTOTP := security.TotpEnabled && user.TOTPConfirmed && user.TOTPSecret != "" + if requiresTOTP { + if !auth.VerifyTOTPCode(user.TOTPSecret, totpCode) { + return errors.New("invalid or missing TOTP code") + } + } + return nil +} + +func (app *App) requireInlineReauth( + request *http.Request, + password string, + totpCode string, +) error { + user, ok := UserFromContext(request.Context()) + if !ok { + return errors.New("authentication required") + } + + settingsPayload, err := loadSettingsOrDefault(app.ConfigDir) + if err != nil { + return err + } + security := effectiveSecurity(settingsPayload.Security) + return verifyActorCredentials(user, password, totpCode, security) +} + +// requireGraceReauth returns an error with message reauth_required when the +// session is outside the grace window. When reauth_sensitive_actions is off, it +// is a no-op. +func (app *App) requireGraceReauth(request *http.Request) error { + settingsPayload, err := loadSettingsOrDefault(app.ConfigDir) + if err != nil { + return err + } + security := effectiveSecurity(settingsPayload.Security) + if !security.ReauthSensitiveActions { + return nil + } + + session, ok := SessionFromContext(request.Context()) + if !ok { + return errors.New("authentication required") + } + + grace := time.Duration(security.ReauthGraceMinutes) * time.Minute + elapsed := time.Since(session.LastReauthAt) + // Allow a short post-reauth window so grace=0 still supports reauth-then-retry. + const justReauthedWindow = 30 * time.Second + if elapsed <= grace || elapsed <= justReauthedWindow { + return nil + } + return errors.New(reauthRequiredMessage) +} + +func writeReauthRequired(writer http.ResponseWriter) { + writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: reauthRequiredMessage}) +} diff --git a/service/internal/api/router.go b/service/internal/api/router.go index 0e5b869..1bf6b11 100644 --- a/service/internal/api/router.go +++ b/service/internal/api/router.go @@ -27,6 +27,12 @@ func NewRouter(configDir string) http.Handler { mux.HandleFunc("POST /api/v1/auth/login", app.loginHandler) mux.HandleFunc("POST /api/v1/auth/logout", app.logoutHandler) mux.HandleFunc("GET /api/v1/auth/me", app.meHandler) + mux.HandleFunc("POST /api/v1/auth/reauth", app.reauthHandler) + + mux.HandleFunc("PUT /api/v1/me/password", app.mePasswordHandler) + mux.HandleFunc("POST /api/v1/me/totp/begin", app.meTOTPBeginHandler) + mux.HandleFunc("POST /api/v1/me/totp/confirm", app.meTOTPConfirmHandler) + mux.HandleFunc("POST /api/v1/me/totp/disable", app.meTOTPDisableHandler) mux.HandleFunc("GET /api/v1/groups", groupsGetHandler(configDir)) mux.HandleFunc("POST /api/v1/groups", groupsUpsertHandler(configDir)) @@ -36,7 +42,14 @@ func NewRouter(configDir string) http.Handler { mux.HandleFunc("GET /api/v1/network", networkGetHandler(configDir)) mux.HandleFunc("PUT /api/v1/network", networkPutHandler(configDir)) mux.HandleFunc("GET /api/v1/users", app.usersGetHandler) + mux.HandleFunc("POST /api/v1/users", app.usersCreateHandler) + mux.HandleFunc("PATCH /api/v1/users", app.usersPatchHandler) mux.HandleFunc("DELETE /api/v1/users", app.usersDeleteHandler) + mux.HandleFunc("GET /api/v1/nodes", app.nodesListHandler) + mux.HandleFunc("POST /api/v1/nodes", app.nodesCreateHandler) + mux.HandleFunc("GET /api/v1/nodes/{id}", app.nodesGetHandler) + mux.HandleFunc("POST /api/v1/nodes/{id}/test-ssh", app.nodesTestSSHHandler) + return app.withCORS(app.withMiddleware(mux)) } diff --git a/service/internal/api/setup_auth_handlers.go b/service/internal/api/setup_auth_handlers.go index f6ae76f..2c9689b 100644 --- a/service/internal/api/setup_auth_handlers.go +++ b/service/internal/api/setup_auth_handlers.go @@ -66,9 +66,12 @@ type loginRequest struct { } type meResponse struct { - UserID string `json:"user_id"` - Username string `json:"username"` - Groups []string `json:"groups"` + 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. @@ -388,11 +391,7 @@ func (app *App) loginHandler(writer http.ResponseWriter, request *http.Request) return } - writeJSON(writer, http.StatusOK, meResponse{ - UserID: matched.ID, - Username: matched.Username, - Groups: matched.GroupNames, - }) + writeJSON(writer, http.StatusOK, app.buildMeResponse(*matched, settingsPayload)) } func (app *App) logoutHandler(writer http.ResponseWriter, request *http.Request) { @@ -408,11 +407,32 @@ func (app *App) meHandler(writer http.ResponseWriter, request *http.Request) { writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "not authenticated"}) return } - writeJSON(writer, http.StatusOK, meResponse{ - UserID: user.ID, - Username: user.Username, - Groups: user.GroupNames, - }) + 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) { diff --git a/service/internal/api/test_helpers.go b/service/internal/api/test_helpers.go index 248e726..de0a502 100644 --- a/service/internal/api/test_helpers.go +++ b/service/internal/api/test_helpers.go @@ -42,9 +42,16 @@ func seedCompletedSetup(t *testing.T, configDir string) *http.Cookie { security := settings.DefaultSecuritySettings() payload := settings.Settings{ SetupCompleted: true, - Groups: []settings.Group{}, - Security: security, - Network: settings.DefaultNetworkSettings(), + Groups: []settings.Group{ + { + Name: settings.AdministratorsGroupName, + ScopeKind: settings.GroupScopeGroup, + ScopeName: "Admin Group", + Permissions: allPermissionList(), + }, + }, + Security: security, + Network: settings.DefaultNetworkSettings(), } if err := settings.SaveSettings(configDir, payload); err != nil { t.Fatalf("SaveSettings: %v", err) diff --git a/service/internal/api/users_handlers.go b/service/internal/api/users_handlers.go index a5f5dda..058e255 100644 --- a/service/internal/api/users_handlers.go +++ b/service/internal/api/users_handlers.go @@ -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 { diff --git a/service/internal/api/users_handlers_test.go b/service/internal/api/users_handlers_test.go index 533dd75..bfcaf9b 100644 --- a/service/internal/api/users_handlers_test.go +++ b/service/internal/api/users_handlers_test.go @@ -64,9 +64,14 @@ func TestUsersDeleteRejectsLastAdministrator(t *testing.T) { router := NewRouter(configDir) request := withSession( - httptest.NewRequest(http.MethodDelete, "/api/v1/users?id=test-user-id", nil), + httptest.NewRequest( + http.MethodDelete, + "/api/v1/users?id=test-user-id", + strings.NewReader(`{"current_password":"correct horse battery staple extra"}`), + ), cookie, ) + request.Header.Set("Content-Type", "application/json") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) @@ -126,11 +131,13 @@ func TestUsersDeleteAllowsNonAdminAndSecondAdmin(t *testing.T) { } router := NewRouter(configDir) + reauthBody := `{"current_password":"correct horse battery staple extra"}` deleteOperator := withSession( - httptest.NewRequest(http.MethodDelete, "/api/v1/users?id=operator-id", nil), + httptest.NewRequest(http.MethodDelete, "/api/v1/users?id=operator-id", strings.NewReader(reauthBody)), cookie, ) + deleteOperator.Header.Set("Content-Type", "application/json") operatorRec := httptest.NewRecorder() router.ServeHTTP(operatorRec, deleteOperator) if operatorRec.Code != http.StatusOK { @@ -138,9 +145,10 @@ func TestUsersDeleteAllowsNonAdminAndSecondAdmin(t *testing.T) { } deleteSecondAdmin := withSession( - httptest.NewRequest(http.MethodDelete, "/api/v1/users?id=admin-two-id", nil), + httptest.NewRequest(http.MethodDelete, "/api/v1/users?id=admin-two-id", strings.NewReader(reauthBody)), cookie, ) + deleteSecondAdmin.Header.Set("Content-Type", "application/json") adminRec := httptest.NewRecorder() router.ServeHTTP(adminRec, deleteSecondAdmin) if adminRec.Code != http.StatusOK { @@ -182,13 +190,275 @@ func TestUsersDeleteUnknownUserNotFound(t *testing.T) { router := NewRouter(configDir) request := withSession( - httptest.NewRequest(http.MethodDelete, "/api/v1/users?id=missing", nil), + httptest.NewRequest( + http.MethodDelete, + "/api/v1/users?id=missing", + strings.NewReader(`{"current_password":"correct horse battery staple extra"}`), + ), cookie, ) + request.Header.Set("Content-Type", "application/json") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusNotFound { - t.Fatalf("expected status %d, got %d", http.StatusNotFound, recorder.Code) + t.Fatalf("expected status %d, got %d body=%s", http.StatusNotFound, recorder.Code, recorder.Body.String()) + } +} + +func TestUsersCreateAndPatch(t *testing.T) { + configDir := t.TempDir() + cookie := seedCompletedSetup(t, configDir) + + key, err := settings.KeyFromEnv() + if err != nil { + t.Fatalf("KeyFromEnv: %v", err) + } + payload, err := settings.LoadSettings(configDir) + if err != nil { + t.Fatalf("LoadSettings: %v", err) + } + payload.Groups = append(payload.Groups, settings.Group{ + Name: "Operators", + ScopeKind: settings.GroupScopeGroup, + ScopeName: "Ops", + Permissions: []string{"nodes.read"}, + }) + if err := settings.SaveSettings(configDir, payload); err != nil { + t.Fatalf("SaveSettings: %v", err) + } + + router := NewRouter(configDir) + + createReq := withSession( + httptest.NewRequest( + http.MethodPost, + "/api/v1/users", + strings.NewReader(`{"username":"alice","password":"correct horse battery staple extra","group_names":["Operators"]}`), + ), + cookie, + ) + createReq.Header.Set("Content-Type", "application/json") + createRec := httptest.NewRecorder() + router.ServeHTTP(createRec, createReq) + if createRec.Code != http.StatusOK { + t.Fatalf("create: expected %d, got %d body=%s", http.StatusOK, createRec.Code, createRec.Body.String()) + } + + var created usersResponseTest + if err := json.NewDecoder(createRec.Body).Decode(&created); err != nil { + t.Fatalf("decode create: %v", err) + } + var aliceID string + for _, user := range created.Users { + if user.Username == "alice" { + aliceID = user.ID + if len(user.GroupNames) != 1 || user.GroupNames[0] != "Operators" { + t.Fatalf("unexpected groups: %#v", user.GroupNames) + } + } + } + if aliceID == "" { + t.Fatal("alice not found after create") + } + + patchReq := withSession( + httptest.NewRequest( + http.MethodPatch, + "/api/v1/users?id="+aliceID, + strings.NewReader(`{"disable_totp":true,"password":"another strong passphrase here"}`), + ), + cookie, + ) + patchReq.Header.Set("Content-Type", "application/json") + patchRec := httptest.NewRecorder() + router.ServeHTTP(patchRec, patchReq) + if patchRec.Code != http.StatusOK { + t.Fatalf("patch: expected %d, got %d body=%s", http.StatusOK, patchRec.Code, patchRec.Body.String()) + } + + store, err := settings.LoadPasswords(configDir, key) + if err != nil { + t.Fatalf("LoadPasswords: %v", err) + } + for _, user := range store.Users { + if user.ID != aliceID { + continue + } + if user.TOTPConfirmed || user.TOTPSecret != "" { + t.Fatal("expected TOTP cleared") + } + ok, err := auth.VerifyPassword("another strong passphrase here", user.PasswordHash) + if err != nil || !ok { + t.Fatal("expected password updated") + } + } +} + +func TestUsersWriteRequiresPermission(t *testing.T) { + configDir := t.TempDir() + cookie := seedCompletedSetup(t, configDir) + + key, err := settings.KeyFromEnv() + if err != nil { + t.Fatalf("KeyFromEnv: %v", err) + } + payload, err := settings.LoadSettings(configDir) + if err != nil { + t.Fatalf("LoadSettings: %v", err) + } + // Strip users.manage from Administrators + for index := range payload.Groups { + if payload.Groups[index].Name != settings.AdministratorsGroupName { + continue + } + filtered := make([]string, 0) + for _, permission := range payload.Groups[index].Permissions { + if permission == "users.manage" { + continue + } + filtered = append(filtered, permission) + } + payload.Groups[index].Permissions = filtered + } + if err := settings.SaveSettings(configDir, payload); err != nil { + t.Fatalf("SaveSettings: %v", err) + } + _ = key + + router := NewRouter(configDir) + request := withSession( + httptest.NewRequest( + http.MethodPost, + "/api/v1/users", + strings.NewReader(`{"username":"bob","password":"correct horse battery staple extra"}`), + ), + cookie, + ) + request.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + if recorder.Code != http.StatusForbidden { + t.Fatalf("expected %d, got %d body=%s", http.StatusForbidden, recorder.Code, recorder.Body.String()) + } +} + +func TestUsersPatchRequiresGraceReauth(t *testing.T) { + configDir := t.TempDir() + cookie := seedCompletedSetup(t, configDir) + + key, err := settings.KeyFromEnv() + if err != nil { + t.Fatalf("KeyFromEnv: %v", err) + } + payload, err := settings.LoadSettings(configDir) + if err != nil { + t.Fatalf("LoadSettings: %v", err) + } + payload.Security.ReauthSensitiveActions = true + payload.Security.ReauthGraceMinutes = 0 + if err := settings.SaveSettings(configDir, payload); err != nil { + t.Fatalf("SaveSettings: %v", err) + } + + // Age the session's LastReauthAt so grace (0) always fails unless MarkReauth just ran. + store, err := settings.LoadSessionsOrEmpty(configDir, key) + if err != nil { + t.Fatalf("LoadSessions: %v", err) + } + for index := range store.Sessions { + store.Sessions[index].LastReauthAt = time.Now().UTC().Add(-time.Minute) + } + if err := settings.SaveSessions(configDir, store, key); err != nil { + t.Fatalf("SaveSessions: %v", err) + } + + router := NewRouter(configDir) + patchReq := withSession( + httptest.NewRequest( + http.MethodPatch, + "/api/v1/users?id=test-user-id", + strings.NewReader(`{"password":"another strong passphrase here"}`), + ), + cookie, + ) + patchReq.Header.Set("Content-Type", "application/json") + patchRec := httptest.NewRecorder() + router.ServeHTTP(patchRec, patchReq) + if patchRec.Code != http.StatusUnauthorized { + t.Fatalf("expected reauth_required status %d, got %d body=%s", http.StatusUnauthorized, patchRec.Code, patchRec.Body.String()) + } + var errPayload apiErrorResponseTest + if err := json.NewDecoder(patchRec.Body).Decode(&errPayload); err != nil { + t.Fatalf("decode: %v", err) + } + if errPayload.Error != "reauth_required" { + t.Fatalf("expected reauth_required, got %q", errPayload.Error) + } + + reauthReq := withSession( + httptest.NewRequest( + http.MethodPost, + "/api/v1/auth/reauth", + strings.NewReader(`{"password":"correct horse battery staple extra"}`), + ), + cookie, + ) + reauthReq.Header.Set("Content-Type", "application/json") + reauthRec := httptest.NewRecorder() + router.ServeHTTP(reauthRec, reauthReq) + if reauthRec.Code != http.StatusOK { + t.Fatalf("reauth: expected %d, got %d body=%s", http.StatusOK, reauthRec.Code, reauthRec.Body.String()) + } + + // Refresh cookie if rotated + for _, setCookie := range reauthRec.Result().Cookies() { + if setCookie.Name == auth.SessionCookieName { + cookie = setCookie + } + } + + retryReq := withSession( + httptest.NewRequest( + http.MethodPatch, + "/api/v1/users?id=test-user-id", + strings.NewReader(`{"password":"another strong passphrase here"}`), + ), + cookie, + ) + retryReq.Header.Set("Content-Type", "application/json") + retryRec := httptest.NewRecorder() + router.ServeHTTP(retryRec, retryReq) + if retryRec.Code != http.StatusOK { + t.Fatalf("retry patch: expected %d, got %d body=%s", http.StatusOK, retryRec.Code, retryRec.Body.String()) + } +} + +func TestMePasswordChange(t *testing.T) { + configDir := t.TempDir() + cookie := seedCompletedSetup(t, configDir) + router := NewRouter(configDir) + + request := withSession( + httptest.NewRequest( + http.MethodPut, + "/api/v1/me/password", + strings.NewReader(`{"current_password":"correct horse battery staple extra","new_password":"fresh strong passphrase words"}`), + ), + cookie, + ) + request.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("expected %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String()) + } + + // Session should be invalidated + meReq := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil), cookie) + meRec := httptest.NewRecorder() + router.ServeHTTP(meRec, meReq) + if meRec.Code != http.StatusUnauthorized { + t.Fatalf("expected session invalidated, got %d", meRec.Code) } } diff --git a/service/internal/auth/session.go b/service/internal/auth/session.go index 608a8c5..bb646db 100644 --- a/service/internal/auth/session.go +++ b/service/internal/auth/session.go @@ -66,11 +66,12 @@ func (manager *SessionManager) CreateSession( } record := settings.SessionRecord{ - ID: sessionID, - UserID: userID, - CreatedAt: now, - LastSeenAt: now, - ExpiresAt: now.Add(lifetime), + ID: sessionID, + UserID: userID, + CreatedAt: now, + LastSeenAt: now, + LastReauthAt: now, + ExpiresAt: now.Add(lifetime), } filtered = append(filtered, record) store.Sessions = filtered @@ -142,6 +143,7 @@ func (manager *SessionManager) LookupValidSession( } found.ID = newID found.CreatedAt = now + // LastReauthAt is preserved across rotation. setSessionCookie(writer, newID, int(found.ExpiresAt.Sub(now).Seconds())) } @@ -153,6 +155,32 @@ func (manager *SessionManager) LookupValidSession( return *found, nil } +// MarkReauth updates LastReauthAt for the session identified by sessionID. +func (manager *SessionManager) MarkReauth(sessionID string) error { + manager.mu.Lock() + defer manager.mu.Unlock() + + store, err := settings.LoadSessionsOrEmpty(manager.configDir, manager.key) + if err != nil { + return err + } + + now := time.Now().UTC() + found := false + for index := range store.Sessions { + if store.Sessions[index].ID != sessionID { + continue + } + store.Sessions[index].LastReauthAt = now + found = true + break + } + if !found { + return ErrSessionNotFound + } + return settings.SaveSessions(manager.configDir, store, manager.key) +} + // InvalidateUserSessions removes all persisted sessions for the given user ID. func (manager *SessionManager) InvalidateUserSessions(userID string) error { manager.mu.Lock() diff --git a/service/internal/auth/ssh_connect.go b/service/internal/auth/ssh_connect.go new file mode 100644 index 0000000..37cc09b --- /dev/null +++ b/service/internal/auth/ssh_connect.go @@ -0,0 +1,73 @@ +package auth + +import ( + "fmt" + "net" + "strings" + "time" + + "golang.org/x/crypto/ssh" +) + +const defaultSSHPort = "22" +const sshDialTimeout = 10 * time.Second + +// TestSSHConnection dials host over SSH using the given private key and username. +// Host key verification is intentionally skipped for this connectivity check; +// trust-on-first-use / known_hosts can be added later. +func TestSSHConnection(hostIP string, username string, privateKeyPEM string, passphrase string) error { + hostIP = strings.TrimSpace(hostIP) + username = strings.TrimSpace(username) + if hostIP == "" { + return fmt.Errorf("host IP is required") + } + if username == "" { + return fmt.Errorf("username is required") + } + + signer, err := parseSigner(privateKeyPEM, passphrase) + if err != nil { + return err + } + + config := &ssh.ClientConfig{ + User: username, + Auth: []ssh.AuthMethod{ + ssh.PublicKeys(signer), + }, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: sshDialTimeout, + } + + address := net.JoinHostPort(hostIP, defaultSSHPort) + client, err := ssh.Dial("tcp", address, config) + if err != nil { + return fmt.Errorf("ssh dial %s: %w", address, err) + } + defer client.Close() + return nil +} + +func parseSigner(privateKeyPEM string, passphrase string) (ssh.Signer, error) { + trimmed := strings.TrimSpace(privateKeyPEM) + if trimmed == "" { + return nil, fmt.Errorf("private key is required") + } + + var ( + signer ssh.Signer + err error + ) + if strings.TrimSpace(passphrase) == "" { + signer, err = ssh.ParsePrivateKey([]byte(trimmed)) + } else { + signer, err = ssh.ParsePrivateKeyWithPassphrase( + []byte(trimmed), + []byte(passphrase), + ) + } + if err != nil { + return nil, fmt.Errorf("parse private key: %w", err) + } + return signer, nil +} diff --git a/service/internal/auth/ssh_connect_test.go b/service/internal/auth/ssh_connect_test.go new file mode 100644 index 0000000..be010cf --- /dev/null +++ b/service/internal/auth/ssh_connect_test.go @@ -0,0 +1,19 @@ +package auth + +import ( + "testing" +) + +func TestTestSSHConnectionRejectsEmptyHost(t *testing.T) { + err := TestSSHConnection("", "clustercanvas", "not-a-key", "") + if err == nil { + t.Fatal("expected error") + } +} + +func TestTestSSHConnectionRejectsInvalidKey(t *testing.T) { + err := TestSSHConnection("127.0.0.1", "clustercanvas", "not-a-key", "") + if err == nil { + t.Fatal("expected error") + } +} diff --git a/service/internal/auth/sshkey.go b/service/internal/auth/sshkey.go new file mode 100644 index 0000000..004cb82 --- /dev/null +++ b/service/internal/auth/sshkey.go @@ -0,0 +1,145 @@ +package auth + +import ( + "crypto" + "crypto/ed25519" + "crypto/rand" + "crypto/rsa" + "encoding/pem" + "errors" + "fmt" + "strings" + + "golang.org/x/crypto/ssh" +) + +const ( + SSHKeyAlgoEd25519 = "ed25519" + SSHKeyAlgoRSA = "rsa" + + DefaultSSHUsername = "clustercanvas" + DefaultRSABits = 4096 + DefaultEd25519Rounds = 100 +) + +// GeneratedSSHKey is the result of generating or importing an SSH key pair. +type GeneratedSSHKey struct { + PrivateKeyPEM string + PublicKey string + Algorithm string + RSABits int + KDFRounds int +} + +// GenerateSSHKey creates an OpenSSH private key and authorized_keys public line. +// +// Private keys are written without an OpenSSH passphrase. At-rest protection is +// provided by ClusterCanvas AES-GCM (node-keys.enc). KDFRounds is retained as +// generation metadata (ssh-keygen -a) for future passphrase-protected exports; +// golang.org/x/crypto/ssh hardcodes bcrypt rounds when encrypting. +func GenerateSSHKey(algorithm string, rsaBits int, kdfRounds int) (GeneratedSSHKey, error) { + algorithm = strings.ToLower(strings.TrimSpace(algorithm)) + switch algorithm { + case SSHKeyAlgoEd25519: + if kdfRounds <= 0 { + kdfRounds = DefaultEd25519Rounds + } + _, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return GeneratedSSHKey{}, fmt.Errorf("generate ed25519 key: %w", err) + } + return marshalGeneratedKey(privateKey, SSHKeyAlgoEd25519, 0, kdfRounds) + case SSHKeyAlgoRSA: + if rsaBits == 0 { + rsaBits = DefaultRSABits + } + if !ValidRSABits(rsaBits) { + return GeneratedSSHKey{}, fmt.Errorf("rsa bits must be one of 2048, 3072, or 4096") + } + privateKey, err := rsa.GenerateKey(rand.Reader, rsaBits) + if err != nil { + return GeneratedSSHKey{}, fmt.Errorf("generate rsa key: %w", err) + } + return marshalGeneratedKey(privateKey, SSHKeyAlgoRSA, rsaBits, 0) + default: + return GeneratedSSHKey{}, fmt.Errorf("algorithm must be %q or %q", SSHKeyAlgoEd25519, SSHKeyAlgoRSA) + } +} + +// ParseSSHPrivateKey accepts an OpenSSH/PEM private key and derives the public key. +func ParseSSHPrivateKey(privateKeyPEM string) (GeneratedSSHKey, error) { + trimmed := strings.TrimSpace(privateKeyPEM) + if trimmed == "" { + return GeneratedSSHKey{}, errors.New("private_key is required") + } + + rawKey, err := ssh.ParseRawPrivateKey([]byte(trimmed)) + if err != nil { + return GeneratedSSHKey{}, fmt.Errorf("invalid private key: %w", err) + } + + signer, ok := rawKey.(crypto.Signer) + if !ok { + return GeneratedSSHKey{}, errors.New("private key does not support signing") + } + + sshPublicKey, err := ssh.NewPublicKey(signer.Public()) + if err != nil { + return GeneratedSSHKey{}, fmt.Errorf("derive public key: %w", err) + } + + algorithm := SSHKeyAlgoEd25519 + rsaBits := 0 + switch key := rawKey.(type) { + case *ed25519.PrivateKey, ed25519.PrivateKey: + algorithm = SSHKeyAlgoEd25519 + case *rsa.PrivateKey: + algorithm = SSHKeyAlgoRSA + rsaBits = key.N.BitLen() + default: + // Keep OpenSSH type string for unusual keys. + algorithm = sshPublicKey.Type() + } + + return GeneratedSSHKey{ + PrivateKeyPEM: trimmed + "\n", + PublicKey: strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPublicKey))), + Algorithm: algorithm, + RSABits: rsaBits, + }, nil +} + +// ValidRSABits reports whether bits is an allowed RSA size. +func ValidRSABits(bits int) bool { + switch bits { + case 2048, 3072, 4096: + return true + default: + return false + } +} + +func marshalGeneratedKey(privateKey crypto.PrivateKey, algorithm string, rsaBits int, kdfRounds int) (GeneratedSSHKey, error) { + block, err := ssh.MarshalPrivateKey(privateKey, "") + if err != nil { + return GeneratedSSHKey{}, fmt.Errorf("marshal private key: %w", err) + } + + signer, ok := privateKey.(crypto.Signer) + if !ok { + return GeneratedSSHKey{}, errors.New("private key does not support signing") + } + sshPublicKey, err := ssh.NewPublicKey(signer.Public()) + if err != nil { + return GeneratedSSHKey{}, fmt.Errorf("derive public key: %w", err) + } + + privatePEM := string(pem.EncodeToMemory(block)) + return GeneratedSSHKey{ + PrivateKeyPEM: privatePEM, + PublicKey: strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPublicKey))), + Algorithm: algorithm, + RSABits: rsaBits, + KDFRounds: kdfRounds, + }, nil +} diff --git a/service/internal/auth/sshkey_test.go b/service/internal/auth/sshkey_test.go new file mode 100644 index 0000000..e688d02 --- /dev/null +++ b/service/internal/auth/sshkey_test.go @@ -0,0 +1,63 @@ +package auth + +import ( + "strings" + "testing" +) + +func TestGenerateSSHKeyEd25519(t *testing.T) { + generated, err := GenerateSSHKey(SSHKeyAlgoEd25519, 0, 100) + if err != nil { + t.Fatalf("GenerateSSHKey: %v", err) + } + if generated.Algorithm != SSHKeyAlgoEd25519 { + t.Fatalf("algorithm = %q", generated.Algorithm) + } + if generated.KDFRounds != 100 { + t.Fatalf("kdf rounds = %d", generated.KDFRounds) + } + if !strings.Contains(generated.PrivateKeyPEM, "BEGIN OPENSSH PRIVATE KEY") { + t.Fatal("expected openssh private key") + } + if !strings.HasPrefix(generated.PublicKey, "ssh-ed25519 ") { + t.Fatalf("public key = %q", generated.PublicKey) + } + + parsed, err := ParseSSHPrivateKey(generated.PrivateKeyPEM) + if err != nil { + t.Fatalf("ParseSSHPrivateKey: %v", err) + } + if parsed.PublicKey != generated.PublicKey { + t.Fatalf("parsed public key mismatch") + } +} + +func TestGenerateSSHKeyRSABits(t *testing.T) { + generated, err := GenerateSSHKey(SSHKeyAlgoRSA, 2048, 0) + if err != nil { + t.Fatalf("GenerateSSHKey: %v", err) + } + if generated.RSABits != 2048 { + t.Fatalf("rsa bits = %d", generated.RSABits) + } + if !strings.HasPrefix(generated.PublicKey, "ssh-rsa ") { + t.Fatalf("public key = %q", generated.PublicKey) + } +} + +func TestGenerateSSHKeyRejectsInvalidRSABits(t *testing.T) { + _, err := GenerateSSHKey(SSHKeyAlgoRSA, 1024, 0) + if err == nil { + t.Fatal("expected error") + } +} + +func TestNewUUIDFormat(t *testing.T) { + value, err := NewUUID() + if err != nil { + t.Fatalf("NewUUID: %v", err) + } + if len(value) != 36 { + t.Fatalf("uuid length = %d", len(value)) + } +} diff --git a/service/internal/auth/uuid.go b/service/internal/auth/uuid.go new file mode 100644 index 0000000..8ff483b --- /dev/null +++ b/service/internal/auth/uuid.go @@ -0,0 +1,24 @@ +package auth + +import ( + "crypto/rand" + "fmt" +) + +// NewUUID returns a random RFC 4122 version-4 UUID string. +func NewUUID() (string, error) { + buffer := make([]byte, 16) + if _, err := rand.Read(buffer); err != nil { + return "", fmt.Errorf("uuid: %w", err) + } + buffer[6] = (buffer[6] & 0x0f) | 0x40 + buffer[8] = (buffer[8] & 0x3f) | 0x80 + return fmt.Sprintf( + "%x-%x-%x-%x-%x", + buffer[0:4], + buffer[4:6], + buffer[6:8], + buffer[8:10], + buffer[10:16], + ), nil +} diff --git a/service/internal/settings/nodes.go b/service/internal/settings/nodes.go new file mode 100644 index 0000000..5146a32 --- /dev/null +++ b/service/internal/settings/nodes.go @@ -0,0 +1,93 @@ +package settings + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" +) + +// LoadNodes reads and parses nodes.json from dir. +func LoadNodes(dir string) (NodeStore, error) { + path := filepath.Join(dir, NodesFileName) + payload, err := os.ReadFile(path) + if err != nil { + return NodeStore{}, err + } + + var store NodeStore + if err := json.Unmarshal(payload, &store); err != nil { + return NodeStore{}, fmt.Errorf("parse nodes: %w", err) + } + if store.Nodes == nil { + store.Nodes = []Node{} + } + return store, nil +} + +// LoadNodesOrEmpty returns an empty store when nodes.json is missing. +func LoadNodesOrEmpty(dir string) (NodeStore, error) { + store, err := LoadNodes(dir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return NodeStore{Nodes: []Node{}}, nil + } + return NodeStore{}, err + } + return store, nil +} + +// SaveNodes writes nodes.json to dir with mode 0640. +func SaveNodes(dir string, store NodeStore) error { + if err := ensureConfigDir(dir); err != nil { + return err + } + if store.Nodes == nil { + store.Nodes = []Node{} + } + + payload, err := json.MarshalIndent(store, "", " ") + if err != nil { + return fmt.Errorf("encode nodes: %w", err) + } + payload = append(payload, '\n') + + path := filepath.Join(dir, NodesFileName) + if err := os.WriteFile(path, payload, 0o640); err != nil { + return fmt.Errorf("write nodes: %w", err) + } + return nil +} + +// LoadNodeKeys decrypts node-keys.enc from dir using key. +func LoadNodeKeys(dir string, key []byte) (NodeKeyStore, error) { + var store NodeKeyStore + if err := loadEncryptedJSON(dir, NodeKeysFileName, key, &store); err != nil { + return NodeKeyStore{}, err + } + if store.Keys == nil { + store.Keys = []NodeKeyEntry{} + } + return store, nil +} + +// LoadNodeKeysOrEmpty returns an empty store when node-keys.enc is missing. +func LoadNodeKeysOrEmpty(dir string, key []byte) (NodeKeyStore, error) { + store, err := LoadNodeKeys(dir, key) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return NodeKeyStore{Keys: []NodeKeyEntry{}}, nil + } + return NodeKeyStore{}, err + } + return store, nil +} + +// SaveNodeKeys encrypts the node key store and writes node-keys.enc. +func SaveNodeKeys(dir string, store NodeKeyStore, key []byte) error { + if store.Keys == nil { + store.Keys = []NodeKeyEntry{} + } + return saveEncryptedJSON(dir, NodeKeysFileName, key, store) +} diff --git a/service/internal/settings/nodes_test.go b/service/internal/settings/nodes_test.go new file mode 100644 index 0000000..76dbc5c --- /dev/null +++ b/service/internal/settings/nodes_test.go @@ -0,0 +1,73 @@ +package settings + +import ( + "encoding/base64" + "testing" + "time" +) + +func TestNodesAndNodeKeysRoundTrip(t *testing.T) { + dir := t.TempDir() + keyBytes := make([]byte, 32) + for index := range keyBytes { + keyBytes[index] = byte(index + 3) + } + t.Setenv(ConfigKeyEnvVar, base64.StdEncoding.EncodeToString(keyBytes)) + + store := NodeStore{ + Nodes: []Node{ + { + ID: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", + Kind: NodeKindContainer, + Name: "ct1", + HostIP: "127.0.0.1", + Username: "clustercanvas", + GroupName: AdministratorsGroupName, + PublicKey: "ssh-ed25519 AAAA", + KeyAlgo: "ed25519", + CreatedAt: time.Now().UTC(), + }, + }, + } + if err := SaveNodes(dir, store); err != nil { + t.Fatalf("SaveNodes: %v", err) + } + loaded, err := LoadNodes(dir) + if err != nil { + t.Fatalf("LoadNodes: %v", err) + } + if len(loaded.Nodes) != 1 || loaded.Nodes[0].Name != "ct1" { + t.Fatalf("loaded = %+v", loaded) + } + + keyStore := NodeKeyStore{ + Keys: []NodeKeyEntry{ + { + NodeID: loaded.Nodes[0].ID, + PrivateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n-----END OPENSSH PRIVATE KEY-----\n", + Algorithm: "ed25519", + KDFRounds: 100, + }, + }, + } + if err := SaveNodeKeys(dir, keyStore, keyBytes); err != nil { + t.Fatalf("SaveNodeKeys: %v", err) + } + loadedKeys, err := LoadNodeKeys(dir, keyBytes) + if err != nil { + t.Fatalf("LoadNodeKeys: %v", err) + } + if len(loadedKeys.Keys) != 1 || loadedKeys.Keys[0].KDFRounds != 100 { + t.Fatalf("keys = %+v", loadedKeys) + } +} + +func TestLoadNodesOrEmptyMissing(t *testing.T) { + store, err := LoadNodesOrEmpty(t.TempDir()) + if err != nil { + t.Fatalf("LoadNodesOrEmpty: %v", err) + } + if len(store.Nodes) != 0 { + t.Fatalf("expected empty, got %d", len(store.Nodes)) + } +} diff --git a/service/internal/settings/path.go b/service/internal/settings/path.go index 9172dd6..dc73384 100644 --- a/service/internal/settings/path.go +++ b/service/internal/settings/path.go @@ -16,6 +16,8 @@ const ( SecretsFileName = "secrets.enc" PasswordsFileName = "passwords.enc" SessionsFileName = "sessions.enc" + NodesFileName = "nodes.json" + NodeKeysFileName = "node-keys.enc" ) // ResolveDir returns the config directory using precedence: diff --git a/service/internal/settings/types.go b/service/internal/settings/types.go index 7a2f461..6858afa 100644 --- a/service/internal/settings/types.go +++ b/service/internal/settings/types.go @@ -85,6 +85,48 @@ type Secrets struct { // Placeholder until Proxmox/API secrets exist. } +// NodeKind identifies which left-nav category a managed host belongs to. +type NodeKind string + +const ( + NodeKindContainer NodeKind = "container" + NodeKindVM NodeKind = "vm" + NodeKindDocker NodeKind = "docker" +) + +// Node is a remote host ClusterCanvas manages over SSH. +type Node struct { + ID string `json:"id"` + Kind NodeKind `json:"kind"` + Name string `json:"name"` + HostIP string `json:"host_ip"` + Username string `json:"username"` + GroupName string `json:"group_name"` + PublicKey string `json:"public_key"` + KeyAlgo string `json:"key_algo"` + CreatedAt time.Time `json:"created_at"` +} + +// NodeStore is the plain JSON payload in nodes.json. +type NodeStore struct { + Nodes []Node `json:"nodes"` +} + +// NodeKeyEntry holds private key material for one node, keyed by node UUID. +type NodeKeyEntry struct { + NodeID string `json:"node_id"` + PrivateKey string `json:"private_key"` + Passphrase string `json:"passphrase,omitempty"` + Algorithm string `json:"algorithm"` + RSABits int `json:"rsa_bits,omitempty"` + KDFRounds int `json:"kdf_rounds,omitempty"` +} + +// NodeKeyStore is the plaintext JSON payload inside node-keys.enc. +type NodeKeyStore struct { + Keys []NodeKeyEntry `json:"keys"` +} + // UserCredential is one account stored in passwords.enc. type UserCredential struct { ID string `json:"id"` @@ -105,11 +147,12 @@ type PasswordStore struct { // SessionRecord is one server-side session stored in sessions.enc. type SessionRecord struct { - ID string `json:"id"` - UserID string `json:"user_id"` - CreatedAt time.Time `json:"created_at"` - LastSeenAt time.Time `json:"last_seen_at"` - ExpiresAt time.Time `json:"expires_at"` + ID string `json:"id"` + UserID string `json:"user_id"` + CreatedAt time.Time `json:"created_at"` + LastSeenAt time.Time `json:"last_seen_at"` + LastReauthAt time.Time `json:"last_reauth_at"` + ExpiresAt time.Time `json:"expires_at"` } // SessionStore is the plaintext JSON payload inside sessions.enc. diff --git a/webui/src/App.css b/webui/src/App.css index f021aa5..b90b108 100644 --- a/webui/src/App.css +++ b/webui/src/App.css @@ -114,10 +114,107 @@ .profile-panel { display: flex; flex-direction: column; - gap: 0.75rem; + gap: 1.5rem; max-width: 40rem; } +.profile-section { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.profile-section-title { + margin: 0; + font-size: 1.1rem; + color: var(--ctp-text); +} + +.profile-form, +.users-create-form, +.reauth-form { + display: flex; + flex-direction: column; + gap: 0.5rem; + max-width: 24rem; +} + +.profile-form label, +.users-create-form label, +.reauth-form label, +.users-inline-edit label { + font-size: 0.85rem; + color: var(--ctp-subtext0); +} + +.profile-form input, +.users-create-form input, +.reauth-form input, +.users-inline-edit input { + padding: 0.45rem 0.6rem; + border: 1px solid var(--ctp-surface1); + border-radius: 6px; + background: var(--ctp-mantle); + color: var(--ctp-text); +} + +.totp-qr { + width: 220px; + height: 220px; + border-radius: 8px; + background: #fff; + padding: 0.5rem; +} + +.users-group-fieldset { + border: 1px solid var(--ctp-surface1); + border-radius: 6px; + padding: 0.5rem 0.75rem; + margin: 0.25rem 0; +} + +.users-row-actions, +.users-inline-actions, +.reauth-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.users-inline-edit { + margin-top: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.reauth-backdrop { + position: fixed; + inset: 0; + background: color-mix(in srgb, var(--ctp-crust) 70%, transparent); + display: flex; + align-items: center; + justify-content: center; + z-index: 40; + padding: 1rem; +} + +.reauth-modal { + background: var(--ctp-base); + border: 1px solid var(--ctp-surface1); + border-radius: 10px; + padding: 1.25rem 1.5rem; + width: min(24rem, 100%); + box-shadow: 0 12px 40px color-mix(in srgb, var(--ctp-crust) 45%, transparent); +} + +.reauth-modal h2 { + margin: 0 0 0.5rem; + font-size: 1.15rem; + color: var(--ctp-text); +} + .content { flex: 1; padding: 2rem 2.5rem; @@ -237,6 +334,162 @@ color: var(--ctp-subtext1); } +.config-error { + margin: 0; + color: var(--ctp-red); + font-size: 0.9rem; +} + +.node-list { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.node-list-item { + padding: 0.75rem 0; + border-bottom: 1px solid var(--ctp-surface0); +} + +.node-list-heading { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.5rem 1rem; + margin-bottom: 0.25rem; +} + +.node-id { + font-size: 0.8rem; + color: var(--ctp-subtext0); +} + +.node-actions { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.4rem; + margin-top: 0.5rem; +} + +.node-test-ssh { + padding: 0.35rem 0.7rem; + border: 1px solid var(--ctp-surface1); + border-radius: 6px; + background: var(--ctp-surface0); + color: var(--ctp-text); + font: inherit; + font-size: 0.85rem; + cursor: pointer; +} + +.node-test-ssh:hover:not(:disabled) { + border-color: var(--ctp-overlay0); +} + +.node-test-ssh:disabled { + opacity: 0.7; + cursor: default; +} + +.node-test-result { + margin: 0; + font-size: 0.85rem; + line-height: 1.4; + max-width: 40rem; + word-break: break-word; +} + +.node-test-result-ok { + color: var(--ctp-green); +} + +.node-test-result-fail { + color: var(--ctp-red); +} + +.resource-add-form { + display: flex; + flex-direction: column; + gap: 0.9rem; + max-width: 36rem; +} + +.resource-add-form .field { + display: flex; + flex-direction: column; + gap: 0.35rem; + font-size: 0.85rem; + color: var(--ctp-subtext0); +} + +.resource-add-form input, +.resource-add-form select, +.resource-add-form textarea { + padding: 0.45rem 0.6rem; + border: 1px solid var(--ctp-surface1); + border-radius: 6px; + background: var(--ctp-mantle); + color: var(--ctp-text); + font: inherit; +} + +.resource-add-form input[readonly], +.resource-add-form textarea[readonly], +.resource-add-form .readonly-field { + background: var(--ctp-surface0); + color: var(--ctp-subtext0); + border-color: var(--ctp-surface0); + cursor: default; + opacity: 1; + font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, + monospace; + font-size: 0.85rem; +} + +.resource-add-form .readonly-field:focus { + outline: none; +} + +.field-group { + margin: 0; + padding: 0.75rem; + border: 1px solid var(--ctp-surface0); + border-radius: 0.35rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.field-group legend { + padding: 0 0.25rem; + font-size: 0.85rem; + color: var(--ctp-subtext0); +} + +.radio-row { + display: flex; + flex-wrap: wrap; + gap: 0.75rem 1.25rem; +} + +.radio-row label { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.9rem; + color: var(--ctp-text); +} + +.created-public-key { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + .roles-panel { display: flex; flex-direction: column; diff --git a/webui/src/App.test.tsx b/webui/src/App.test.tsx index 92dce67..212b388 100644 --- a/webui/src/App.test.tsx +++ b/webui/src/App.test.tsx @@ -24,6 +24,18 @@ function stubFetchOk() { user_id: 'u1', username: 'Admin', groups: ['Administrators'], + totp_confirmed: false, + totp_enabled: false, + permissions: [ + 'nodes.read', + 'nodes.exec', + 'nodes.update', + 'jobs.read', + 'jobs.run', + 'users.manage', + 'secrets.manage', + 'roles.manage', + ], }), }) } @@ -48,7 +60,25 @@ function stubFetchOk() { if (url.includes('/api/v1/groups')) { return Promise.resolve({ ok: true, - json: async () => ({ groups: [] }), + json: async () => ({ + groups: [ + { + name: 'Administrators', + scope_kind: 'group', + scope_name: 'Admin Group', + permissions: [ + 'nodes.read', + 'nodes.exec', + 'nodes.update', + 'jobs.read', + 'jobs.run', + 'users.manage', + 'secrets.manage', + 'roles.manage', + ], + }, + ], + }), }) } @@ -113,6 +143,13 @@ function stubFetchOk() { }) } + if (url.includes('/api/v1/nodes')) { + return Promise.resolve({ + ok: true, + json: async () => ({ nodes: [] }), + }) + } + return Promise.resolve({ ok: true, json: async () => ({}), @@ -197,7 +234,10 @@ describe('App', () => { ).toBeInTheDocument() expect(window.location.pathname).toBe('/profile') expect( - screen.getByText(/Your account settings will live here/i), + screen.getByRole('heading', { name: 'Change password' }), + ).toBeInTheDocument() + expect( + screen.getByRole('heading', { name: 'Authenticator (TOTP)' }), ).toBeInTheDocument() }) @@ -229,12 +269,28 @@ describe('App', () => { screen.getByRole('heading', { name: 'Virtual Machines', level: 2 }), ).toBeInTheDocument() expect(window.location.pathname).toBe('/vms') + expect(screen.getByRole('tab', { name: 'Overview' })).toHaveAttribute( + 'aria-selected', + 'true', + ) + expect( + screen.getByRole('tab', { name: 'Add Virtual Machines' }), + ).toBeInTheDocument() await user.click(screen.getByRole('button', { name: 'Docker' })) expect( screen.getByRole('heading', { name: 'Docker', level: 2 }), ).toBeInTheDocument() expect(window.location.pathname).toBe('/docker') + expect(screen.getByRole('tab', { name: 'Add Docker' })).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Containers' })) + expect(window.location.pathname).toBe('/containers') + await user.click(screen.getByRole('tab', { name: 'Add Containers' })) + expect(window.location.pathname).toBe('/containers/add') + expect(await screen.findByLabelText('Node UUID')).toBeInTheDocument() + expect(screen.getByLabelText('SSH username')).toHaveValue('clustercanvas') + expect(screen.getByLabelText('KDF rounds')).toHaveValue(100) await user.click(screen.getByRole('button', { name: 'Configuration' })) expect( @@ -404,6 +460,18 @@ describe('App', () => { user_id: 'u1', username: 'Admin', groups: ['Administrators'], + totp_confirmed: false, + totp_enabled: false, + permissions: [ + 'nodes.read', + 'nodes.exec', + 'nodes.update', + 'jobs.read', + 'jobs.run', + 'users.manage', + 'secrets.manage', + 'roles.manage', + ], }), }) } @@ -419,6 +487,27 @@ describe('App', () => { json: async () => ({ service: 'clustercanvas', version: '0.1.0' }), }) } + if (url.includes('/api/v1/groups')) { + return Promise.resolve({ + ok: true, + json: async () => ({ + groups: [ + { + name: 'Administrators', + scope_kind: 'group', + scope_name: 'Admin Group', + permissions: ['users.manage'], + }, + { + name: 'Operators', + scope_kind: 'group', + scope_name: 'Ops', + permissions: ['nodes.read'], + }, + ], + }), + }) + } if (url.includes('/api/v1/users') && init?.method === 'DELETE') { return Promise.resolve({ ok: true, @@ -484,6 +573,14 @@ describe('App', () => { const deleteButtons = screen.getAllByRole('button', { name: 'Delete' }) await user.click(deleteButtons[1]) + expect(await screen.findByRole('heading', { name: 'Confirm delete' })).toBeInTheDocument() + const dialog = screen.getByRole('dialog') + await user.type( + dialog.querySelector('#reauth-password') as HTMLInputElement, + 'correct horse battery staple extra', + ) + await user.click(screen.getByRole('button', { name: 'Delete user' })) + expect(await screen.findByText('Configured accounts')).toBeInTheDocument() expect(screen.queryByText('operator')).not.toBeInTheDocument() expect(screen.getByText('Admin', { selector: '.user-badge' })).toBeInTheDocument() diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 0cf0267..e9ddd04 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -1,20 +1,35 @@ -import { useEffect, useState } from 'react' +import { useEffect, useState, type FormEvent } from 'react' import { + beginMyTOTP, + changeMyPassword, + confirmMyTOTP, + createNode, + createUser, deleteGroup, deleteUser, DEFAULT_NETWORK_SETTINGS, DEFAULT_SECURITY_SETTINGS, + disableMyTOTP, fetchGroups, fetchMe, fetchNetwork, + fetchNodes, fetchSecurity, fetchSetupStatus, fetchStatus, fetchUsers, + isReauthRequiredError, + patchUser, + reauth, + testNodeSSH, type AccessMode, type Group, type GroupScopeKind, + type MeResponse, type NetworkSettings, + type Node, + type NodeKind, + type ReauthCredentials, type SecuritySettings, type User, saveNetwork, @@ -23,17 +38,22 @@ import { } from './api/client' import { LoginPage } from './LoginPage' import { + isResourceSectionId, navigateTo, parseLocation, pathFor, subscribeToLocation, type ConfigTabId, + type ResourceSectionId, + type ResourceTabId, type SectionId, } from './navigation' import { validateHostname, validateListenAddress, + validatePassword, } from './passwordPolicy' +import { ReauthModal } from './ReauthModal' import { SetupWizard } from './SetupWizard' import { applyThemeFromPreference, @@ -73,6 +93,18 @@ const CONFIG_TABS: ReadonlyArray<{ id: ConfigTabId; label: string }> = [ { id: 'advanced', label: 'Advanced' }, ] +const RESOURCE_SECTION_KIND: Record = { + containers: 'container', + vms: 'vm', + docker: 'docker', +} + +const RESOURCE_ADD_LABEL: Record = { + containers: 'Add Containers', + vms: 'Add Virtual Machines', + docker: 'Add Docker', +} + const DEFAULT_PERMISSIONS = [ 'nodes.read', 'nodes.exec', @@ -130,14 +162,758 @@ function UserIcon() { ) } -function ProfilePanel() { +function ProfilePanel({ + onSignedOut, +}: { + onSignedOut: () => void +}) { + const [me, setMe] = useState(null) + const [loadError, setLoadError] = useState(null) + const [currentPassword, setCurrentPassword] = useState('') + const [newPassword, setNewPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [passwordTotp, setPasswordTotp] = useState('') + const [passwordError, setPasswordError] = useState(null) + const [passwordSuccess, setPasswordSuccess] = useState(null) + + const [totpPassword, setTotpPassword] = useState('') + const [totpReauthCode, setTotpReauthCode] = useState('') + const [pendingSecret, setPendingSecret] = useState(null) + const [pendingUrl, setPendingUrl] = useState(null) + const [qrDataUrl, setQrDataUrl] = useState(null) + const [confirmCode, setConfirmCode] = useState('') + const [disableCode, setDisableCode] = useState('') + const [totpError, setTotpError] = useState(null) + const [totpMessage, setTotpMessage] = useState(null) + + useEffect(() => { + let isCancelled = false + async function load() { + try { + const response = await fetchMe() + if (!isCancelled) { + if (!response) { + onSignedOut() + return + } + setMe(response) + } + } catch (err) { + if (!isCancelled) { + setLoadError(err instanceof Error ? err.message : 'Failed to load profile') + } + } + } + void load() + return () => { + isCancelled = true + } + }, [onSignedOut]) + + useEffect(() => { + if (!pendingUrl) { + setQrDataUrl(null) + return + } + let isCancelled = false + void import('qrcode').then(async (qrcode) => { + const dataUrl = await qrcode.toDataURL(pendingUrl, { width: 220, margin: 1 }) + if (!isCancelled) { + setQrDataUrl(dataUrl) + } + }) + return () => { + isCancelled = true + } + }, [pendingUrl]) + + async function handleChangePassword(event: FormEvent) { + event.preventDefault() + setPasswordError(null) + setPasswordSuccess(null) + if (!me) { + return + } + if (newPassword !== confirmPassword) { + setPasswordError('New passwords do not match') + return + } + const policyError = validatePassword(newPassword, me.username) + if (policyError) { + setPasswordError(policyError) + return + } + try { + await changeMyPassword(currentPassword, newPassword, passwordTotp) + setPasswordSuccess('Password updated. Sign in again.') + setCurrentPassword('') + setNewPassword('') + setConfirmPassword('') + setPasswordTotp('') + onSignedOut() + } catch (err) { + setPasswordError(err instanceof Error ? err.message : 'Password change failed') + } + } + + async function handleBeginTotp(event: FormEvent) { + event.preventDefault() + setTotpError(null) + setTotpMessage(null) + try { + const began = await beginMyTOTP({ + current_password: totpPassword, + totp_code: totpReauthCode, + }) + setPendingSecret(began.secret) + setPendingUrl(began.otpauth_url) + setTotpMessage('Scan the QR code, then enter a code to confirm.') + } catch (err) { + setTotpError(err instanceof Error ? err.message : 'Failed to start TOTP enrollment') + } + } + + async function handleConfirmTotp(event: FormEvent) { + event.preventDefault() + if (!pendingSecret) { + return + } + setTotpError(null) + try { + await confirmMyTOTP(pendingSecret, confirmCode, { + current_password: totpPassword, + totp_code: totpReauthCode, + }) + setPendingSecret(null) + setPendingUrl(null) + setConfirmCode('') + setTotpPassword('') + setTotpReauthCode('') + setTotpMessage('Authenticator enabled.') + const refreshed = await fetchMe() + if (refreshed) { + setMe(refreshed) + } + } catch (err) { + setTotpError(err instanceof Error ? err.message : 'Failed to confirm TOTP') + } + } + + async function handleDisableTotp(event: FormEvent) { + event.preventDefault() + setTotpError(null) + try { + await disableMyTOTP({ + current_password: totpPassword, + totp_code: disableCode, + }) + setDisableCode('') + setTotpPassword('') + setTotpMessage('Authenticator disabled.') + const refreshed = await fetchMe() + if (refreshed) { + setMe(refreshed) + } + } catch (err) { + setTotpError(err instanceof Error ? err.message : 'Failed to disable TOTP') + } + } + + if (loadError) { + return ( +
+

+ {loadError} +

+
+ ) + } + + if (!me) { + return ( +
+

Loading…

+
+ ) + } + return (
-

- Your account settings will live here — enroll TOTP when the - administrator has enabled it, and manage other profile details. -

-

Coming soon

+
+

Account

+

Signed in as {me.username}

+
+ +
+

Change password

+
void handleChangePassword(event)}> + + setCurrentPassword(event.target.value)} + required + /> + + setNewPassword(event.target.value)} + required + /> + + setConfirmPassword(event.target.value)} + required + /> + {me.totp_confirmed ? ( + <> + + setPasswordTotp(event.target.value)} + required + /> + + ) : null} + {passwordError ? ( +

+ {passwordError} +

+ ) : null} + {passwordSuccess ? ( +

+ {passwordSuccess} +

+ ) : null} + +
+
+ +
+

Authenticator (TOTP)

+ {!me.totp_enabled ? ( +

+ An administrator must enable TOTP on the server before you can enroll. +

+ ) : me.totp_confirmed ? ( +
void handleDisableTotp(event)}> +

Authenticator is enabled on your account.

+ + setTotpPassword(event.target.value)} + required + /> + + setDisableCode(event.target.value)} + required + /> + {totpError ? ( +

+ {totpError} +

+ ) : null} + {totpMessage ? ( +

+ {totpMessage} +

+ ) : null} + +
+ ) : pendingSecret ? ( +
void handleConfirmTotp(event)}> + {qrDataUrl ? ( + TOTP QR code + ) : null} +

Secret: {pendingSecret}

+ + setConfirmCode(event.target.value)} + required + /> + {totpError ? ( +

+ {totpError} +

+ ) : null} + +
+ ) : ( +
void handleBeginTotp(event)}> +

+ Re-enter your password to begin authenticator enrollment. +

+ + setTotpPassword(event.target.value)} + required + /> + {totpError ? ( +

+ {totpError} +

+ ) : null} + {totpMessage ? ( +

+ {totpMessage} +

+ ) : null} + +
+ )} +
+
+ ) +} + +function UsersConfigTab() { + const [users, setUsers] = useState>([]) + const [groups, setGroups] = useState>([]) + const [me, setMe] = useState(null) + const [isLoadingUsers, setIsLoadingUsers] = useState(true) + const [usersLoadError, setUsersLoadError] = useState(null) + const [actionError, setActionError] = useState(null) + + const [createUsername, setCreateUsername] = useState('') + const [createPassword, setCreatePassword] = useState('') + const [createGroupNames, setCreateGroupNames] = useState>([]) + + const [editingUserId, setEditingUserId] = useState(null) + const [editGroupNames, setEditGroupNames] = useState>([]) + const [setPasswordUserId, setSetPasswordUserId] = useState(null) + const [setPasswordValue, setSetPasswordValue] = useState('') + + type PendingAction = + | { kind: 'delete'; userId: string } + | { + kind: 'grace' + run: (credentials?: ReauthCredentials) => Promise + } + const [pendingAction, setPendingAction] = useState(null) + + const canManageUsers = Boolean(me?.permissions?.includes('users.manage')) + const requireTotp = Boolean(me?.totp_confirmed && me?.totp_enabled) + + useEffect(() => { + let isCancelled = false + + async function loadUsers() { + setIsLoadingUsers(true) + setUsersLoadError(null) + try { + const [usersResponse, groupsResponse, meResponse] = await Promise.all([ + fetchUsers(), + fetchGroups(), + fetchMe(), + ]) + if (!isCancelled) { + setUsers(usersResponse.users) + setGroups(groupsResponse.groups) + setMe(meResponse) + } + } catch (err) { + if (!isCancelled) { + setUsersLoadError( + err instanceof Error ? err.message : 'Failed to load users', + ) + } + } finally { + if (!isCancelled) { + setIsLoadingUsers(false) + } + } + } + + void loadUsers() + + return () => { + isCancelled = true + } + }, []) + + function toggleGroupSelection( + current: ReadonlyArray, + groupName: string, + ): ReadonlyArray { + if (current.includes(groupName)) { + return current.filter((name) => name !== groupName) + } + return [...current, groupName] + } + + async function runGraceMutation(mutation: () => Promise) { + setActionError(null) + try { + await mutation() + } catch (err) { + if (isReauthRequiredError(err)) { + setPendingAction({ + kind: 'grace', + run: async () => { + // caller will reauth first then call mutation again + await mutation() + }, + }) + return + } + setActionError(err instanceof Error ? err.message : 'Action failed') + } + } + + async function handleCreateUser(event: FormEvent) { + event.preventDefault() + setActionError(null) + const policyError = validatePassword(createPassword, createUsername) + if (policyError) { + setActionError(policyError) + return + } + try { + const response = await createUser({ + username: createUsername, + password: createPassword, + group_names: createGroupNames, + }) + setUsers(response.users) + setCreateUsername('') + setCreatePassword('') + setCreateGroupNames([]) + } catch (err) { + setActionError(err instanceof Error ? err.message : 'Failed to create user') + } + } + + async function handleSaveGroups(userId: string) { + await runGraceMutation(async () => { + const response = await patchUser(userId, { group_names: editGroupNames }) + setUsers(response.users) + setEditingUserId(null) + }) + } + + async function handleSetPassword(userId: string) { + const target = users.find((user) => user.id === userId) + const policyError = validatePassword(setPasswordValue, target?.username ?? '') + if (policyError) { + setActionError(policyError) + return + } + await runGraceMutation(async () => { + const response = await patchUser(userId, { password: setPasswordValue }) + setUsers(response.users) + setSetPasswordUserId(null) + setSetPasswordValue('') + }) + } + + async function handleDisableTotp(userId: string) { + await runGraceMutation(async () => { + const response = await patchUser(userId, { disable_totp: true }) + setUsers(response.users) + }) + } + + async function handleDeleteConfirm(credentials: ReauthCredentials) { + if (!pendingAction || pendingAction.kind !== 'delete') { + return + } + const response = await deleteUser(pendingAction.userId, credentials) + setUsers(response.users) + setPendingAction(null) + } + + async function handleGraceConfirm(credentials: ReauthCredentials) { + if (!pendingAction || pendingAction.kind !== 'grace') { + return + } + await reauth(credentials.current_password, credentials.totp_code ?? '') + await pendingAction.run() + setPendingAction(null) + } + + return ( +
+ {pendingAction?.kind === 'delete' ? ( + setPendingAction(null)} + onConfirm={handleDeleteConfirm} + /> + ) : null} + {pendingAction?.kind === 'grace' ? ( + setPendingAction(null)} + onConfirm={handleGraceConfirm} + /> + ) : null} + + {canManageUsers ? ( +
void handleCreateUser(event)} + > +

Create user

+ + setCreateUsername(event.target.value)} + required + /> + + setCreatePassword(event.target.value)} + required + /> +
+ Groups (optional) + {groups.map((group) => ( + + ))} +
+ +
+ ) : null} + +
+

Configured accounts

+ {actionError ? ( +

+ {actionError} +

+ ) : null} + {isLoadingUsers ? ( +

Loading…

+ ) : usersLoadError ? ( +

+ {usersLoadError} +

+ ) : users.length === 0 ? ( +

No users configured yet.

+ ) : ( +
    + {users.map((user) => ( +
  • +
    +
    + {user.username} + {user.is_admin ? ( + Admin + ) : null} +
    +
    + Groups:{' '} + {user.group_names.length > 0 + ? user.group_names.join(', ') + : '—'} +
    +
    + Enabled: {user.enabled ? 'yes' : 'no'} · TOTP:{' '} + {user.totp_confirmed ? 'confirmed' : 'not confirmed'} +
    +
    + Created: {formatUserTimestamp(user.created_at)} · Password + changed: {formatUserTimestamp(user.password_changed_at)} +
    +
    ID: {user.id}
    + + {canManageUsers && editingUserId === user.id ? ( +
    +
    + Groups + {groups.map((group) => ( + + ))} +
    +
    + + +
    +
    + ) : null} + + {canManageUsers && setPasswordUserId === user.id ? ( +
    + + setSetPasswordValue(event.target.value)} + /> +
    + + +
    +
    + ) : null} +
    + {canManageUsers ? ( +
    + + + {user.totp_confirmed ? ( + + ) : null} + +
    + ) : ( + + )} +
  • + ))} +
+ )} +
) } @@ -483,122 +1259,6 @@ function GroupsConfigTab() { ) } -function UsersConfigTab() { - const [users, setUsers] = useState>([]) - const [isLoadingUsers, setIsLoadingUsers] = useState(true) - const [usersLoadError, setUsersLoadError] = useState(null) - const [actionError, setActionError] = useState(null) - - useEffect(() => { - let isCancelled = false - - async function loadUsers() { - setIsLoadingUsers(true) - setUsersLoadError(null) - try { - const response = await fetchUsers() - if (!isCancelled) { - setUsers(response.users) - } - } catch (err) { - if (!isCancelled) { - setUsersLoadError( - err instanceof Error ? err.message : 'Failed to load users', - ) - } - } finally { - if (!isCancelled) { - setIsLoadingUsers(false) - } - } - } - - void loadUsers() - - return () => { - isCancelled = true - } - }, []) - - async function handleDeleteUser(userId: string) { - setActionError(null) - - try { - const response = await deleteUser(userId) - setUsers(response.users) - } catch (err) { - setActionError( - err instanceof Error ? err.message : 'Failed to delete user', - ) - } - } - - return ( -
-
-

Configured accounts

- {actionError ? ( -

- {actionError} -

- ) : null} - {isLoadingUsers ? ( -

Loading…

- ) : usersLoadError ? ( -

- {usersLoadError} -

- ) : users.length === 0 ? ( -

No users configured yet.

- ) : ( -
    - {users.map((user) => ( -
  • -
    -
    - {user.username} - {user.is_admin ? ( - Admin - ) : null} -
    -
    - Groups:{' '} - {user.group_names.length > 0 - ? user.group_names.join(', ') - : '—'} -
    -
    - Enabled: {user.enabled ? 'yes' : 'no'} · TOTP:{' '} - {user.totp_confirmed ? 'confirmed' : 'not confirmed'} -
    -
    - Created: {formatUserTimestamp(user.created_at)} · Password - changed: {formatUserTimestamp(user.password_changed_at)} -
    -
    ID: {user.id}
    -
    - -
  • - ))} -
- )} -
-
- ) -} - function NetworkConfigTab() { const [draft, setDraft] = useState(DEFAULT_NETWORK_SETTINGS) const [rulesText, setRulesText] = useState('') @@ -973,8 +1633,7 @@ function SecurityConfigTab() { Enable TOTP

- When enabled, users will be able to use authenticator apps (enrollment - comes later). + When enabled, users can enroll authenticator apps from their profile.

@@ -1096,6 +1755,581 @@ function ConfigTabPanelContent({ return

Coming soon

} +function resourceTabsFor(section: ResourceSectionId): ReadonlyArray<{ + id: ResourceTabId + label: string +}> { + return [ + { id: 'overview', label: 'Overview' }, + { id: 'security', label: 'Security' }, + { id: 'add', label: RESOURCE_ADD_LABEL[section] }, + ] +} + +function ResourceOverviewTab({ + section, + canRead, + canExec, +}: { + section: ResourceSectionId + canRead: boolean + canExec: boolean +}) { + const [nodes, setNodes] = useState>([]) + const [isLoading, setIsLoading] = useState(true) + const [loadError, setLoadError] = useState(null) + const [testingNodeId, setTestingNodeId] = useState(null) + const [testResults, setTestResults] = useState( + {} as Record, + ) + + useEffect(() => { + if (!canRead) { + setIsLoading(false) + setNodes([]) + return + } + + let isCancelled = false + + async function loadNodes() { + setIsLoading(true) + setLoadError(null) + try { + const response = await fetchNodes(RESOURCE_SECTION_KIND[section]) + if (!isCancelled) { + setNodes(response.nodes) + } + } catch (err) { + if (!isCancelled) { + setLoadError( + err instanceof Error ? err.message : 'Failed to load nodes', + ) + } + } finally { + if (!isCancelled) { + setIsLoading(false) + } + } + } + + void loadNodes() + + return () => { + isCancelled = true + } + }, [section, canRead]) + + async function handleTestSSH(nodeId: string) { + setTestingNodeId(nodeId) + setTestResults((previous) => { + const next = { ...previous } + delete next[nodeId] + return next + }) + try { + const result = await testNodeSSH(nodeId) + setTestResults((previous) => ({ + ...previous, + [nodeId]: { ok: result.ok, message: result.message }, + })) + } catch (err) { + setTestResults((previous) => ({ + ...previous, + [nodeId]: { + ok: false, + message: err instanceof Error ? err.message : 'SSH test failed', + }, + })) + } finally { + setTestingNodeId(null) + } + } + + if (!canRead) { + return ( +

+ You need the nodes.read permission to view managed hosts. +

+ ) + } + + if (isLoading) { + return

Loading…

+ } + + if (loadError) { + return

{loadError}

+ } + + if (nodes.length === 0) { + return ( +

+ No hosts registered yet. Use the {RESOURCE_ADD_LABEL[section]} tab to + add one. +

+ ) + } + + return ( +
    + {nodes.map((node) => { + const testResult = testResults[node.id] + return ( +
  • +
    + {node.name} + {node.id} +
    +

    + {node.host_ip} · {node.username} · group {node.group_name} ·{' '} + {node.key_algo} +

    + {canExec ? ( +
    + + {testResult ? ( +

    + {testResult.ok ? 'Succeeded: ' : 'Failed: '} + {testResult.message} +

    + ) : null} +
    + ) : null} +
  • + ) + })} +
+ ) +} + +function ResourceSecurityTab({ section }: { section: ResourceSectionId }) { + return ( +
+

+ Security controls for {SECTION_TITLES[section].toLowerCase()} hosts + (key rotation, hardening, and access policy) will live here. +

+
+ ) +} + +function ResourceAddTab({ + section, + canExec, + onCreated, +}: { + section: ResourceSectionId + canExec: boolean + onCreated: () => void +}) { + const [nodeId, setNodeId] = useState(() => crypto.randomUUID()) + const [name, setName] = useState('') + const [hostIP, setHostIP] = useState('') + const [username, setUsername] = useState('clustercanvas') + const [keyMode, setKeyMode] = useState<'generate' | 'paste'>('generate') + const [algorithm, setAlgorithm] = useState<'ed25519' | 'rsa'>('ed25519') + const [rsaBits, setRsaBits] = useState(4096) + const [kdfRounds, setKdfRounds] = useState(100) + const [privateKey, setPrivateKey] = useState('') + const [groupMode, setGroupMode] = useState<'existing' | 'new'>('existing') + const [groupName, setGroupName] = useState('') + const [newGroupName, setNewGroupName] = useState('') + const [groups, setGroups] = useState>([]) + const [createdPublicKey, setCreatedPublicKey] = useState(null) + const [submitError, setSubmitError] = useState(null) + const [isSubmitting, setIsSubmitting] = useState(false) + + useEffect(() => { + let isCancelled = false + + async function loadGroups() { + try { + const response = await fetchGroups() + if (!isCancelled) { + setGroups(response.groups) + setGroupName((current) => + current === '' && response.groups.length > 0 + ? response.groups[0].name + : current, + ) + } + } catch { + // Group list is best-effort for the form. + } + } + + void loadGroups() + + return () => { + isCancelled = true + } + }, []) + + if (!canExec) { + return ( +

+ You need the nodes.exec permission to register a host. +

+ ) + } + + async function handleSubmit(event: FormEvent) { + event.preventDefault() + setSubmitError(null) + setIsSubmitting(true) + try { + const response = await createNode({ + id: nodeId, + kind: RESOURCE_SECTION_KIND[section], + name: name.trim(), + host_ip: hostIP.trim(), + username: username.trim() || 'clustercanvas', + ...(groupMode === 'new' + ? { new_group: { name: newGroupName.trim() } } + : { group_name: groupName }), + ...(keyMode === 'generate' + ? { + generate: { + algorithm, + ...(algorithm === 'rsa' ? { rsa_bits: rsaBits } : {}), + ...(algorithm === 'ed25519' ? { kdf_rounds: kdfRounds } : {}), + }, + } + : { private_key: privateKey }), + }) + setCreatedPublicKey(response.node.public_key) + setName('') + setHostIP('') + setPrivateKey('') + setNodeId(crypto.randomUUID()) + onCreated() + } catch (err) { + setSubmitError(err instanceof Error ? err.message : 'Failed to create node') + } finally { + setIsSubmitting(false) + } + } + + return ( +
+ + + + + + + + +
+ SSH key +
+ + +
+ + {keyMode === 'generate' ? ( + <> +
+ + +
+ {algorithm === 'rsa' ? ( + + ) : ( + + )} + + ) : ( +