Add SSH-managed node registry with connection testing and reauth.
Register hosts under Containers/VMs/Docker with encrypted key storage, and require re-authentication for sensitive account changes.
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
+254
-1
@@ -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;
|
||||
|
||||
+99
-2
@@ -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()
|
||||
|
||||
+1394
-130
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,96 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import type { ReauthCredentials } from './api/client'
|
||||
|
||||
type ReauthModalProps = {
|
||||
title: string
|
||||
hint?: string
|
||||
requireTotp: boolean
|
||||
confirmLabel?: string
|
||||
onCancel: () => void
|
||||
onConfirm: (credentials: ReauthCredentials) => Promise<void>
|
||||
}
|
||||
|
||||
export function ReauthModal({
|
||||
title,
|
||||
hint,
|
||||
requireTotp,
|
||||
confirmLabel = 'Confirm',
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: ReauthModalProps) {
|
||||
const [password, setPassword] = useState('')
|
||||
const [totpCode, setTotpCode] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
setError(null)
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await onConfirm({
|
||||
current_password: password,
|
||||
totp_code: totpCode,
|
||||
})
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Re-authentication failed')
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="reauth-backdrop" role="presentation">
|
||||
<div
|
||||
className="reauth-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="reauth-title"
|
||||
>
|
||||
<h2 id="reauth-title">{title}</h2>
|
||||
{hint ? <p className="config-hint">{hint}</p> : null}
|
||||
<form className="reauth-form" onSubmit={(event) => void handleSubmit(event)}>
|
||||
<label htmlFor="reauth-password">Password</label>
|
||||
<input
|
||||
id="reauth-password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
required
|
||||
/>
|
||||
{requireTotp ? (
|
||||
<>
|
||||
<label htmlFor="reauth-totp">Authenticator code</label>
|
||||
<input
|
||||
id="reauth-totp"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
value={totpCode}
|
||||
onChange={(event) => setTotpCode(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p className="groups-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="reauth-actions">
|
||||
<button type="button" className="config-action-revert" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="config-action-save"
|
||||
disabled={isSubmitting || !password}
|
||||
>
|
||||
{isSubmitting ? 'Checking…' : confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -288,17 +288,28 @@ describe('deleteUser', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('sends DELETE by id and returns users', async () => {
|
||||
it('sends DELETE by id with reauth credentials and returns users', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ users: [] }),
|
||||
})
|
||||
|
||||
await deleteUser('u2', fetchMock)
|
||||
await deleteUser(
|
||||
'u2',
|
||||
{ current_password: 'correct horse battery staple extra' },
|
||||
fetchMock,
|
||||
)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8080/api/v1/users?id=u2',
|
||||
expect.objectContaining({ method: 'DELETE', credentials: 'include' }),
|
||||
expect.objectContaining({
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
current_password: 'correct horse battery staple extra',
|
||||
totp_code: '',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -309,9 +320,9 @@ describe('deleteUser', () => {
|
||||
json: async () => ({ error: 'cannot delete the last administrator' }),
|
||||
})
|
||||
|
||||
await expect(deleteUser('u1', fetchMock)).rejects.toThrow(
|
||||
'cannot delete the last administrator',
|
||||
)
|
||||
await expect(
|
||||
deleteUser('u1', { current_password: 'x' }, fetchMock),
|
||||
).rejects.toThrow('cannot delete the last administrator')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -463,3 +474,86 @@ describe('saveNetwork', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('nodes API', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('fetches nodes filtered by kind', async () => {
|
||||
const { fetchNodes } = await import('./client')
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ nodes: [] }),
|
||||
})
|
||||
|
||||
await fetchNodes('container', fetchMock)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8080/api/v1/nodes?kind=container',
|
||||
expect.objectContaining({ credentials: 'include' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('creates a node with generate options', async () => {
|
||||
const { createNode } = await import('./client')
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
node: {
|
||||
id: '11111111-2222-4333-8444-555555555555',
|
||||
kind: 'container',
|
||||
name: 'ct1',
|
||||
host_ip: '10.0.0.1',
|
||||
username: 'clustercanvas',
|
||||
group_name: 'Administrators',
|
||||
public_key: 'ssh-ed25519 AAAA',
|
||||
key_algo: 'ed25519',
|
||||
created_at: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
await createNode(
|
||||
{
|
||||
kind: 'container',
|
||||
name: 'ct1',
|
||||
host_ip: '10.0.0.1',
|
||||
username: 'clustercanvas',
|
||||
group_name: 'Administrators',
|
||||
generate: { algorithm: 'ed25519', kdf_rounds: 100 },
|
||||
},
|
||||
fetchMock,
|
||||
)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8080/api/v1/nodes',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('posts to the SSH test endpoint', async () => {
|
||||
const { testNodeSSH } = await import('./client')
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ ok: false, message: 'connection refused' }),
|
||||
})
|
||||
|
||||
const result = await testNodeSSH(
|
||||
'11111111-2222-4333-8444-555555555555',
|
||||
fetchMock,
|
||||
)
|
||||
|
||||
expect(result).toEqual({ ok: false, message: 'connection refused' })
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8080/api/v1/nodes/11111111-2222-4333-8444-555555555555/test-ssh',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
+289
-4
@@ -84,6 +84,32 @@ export type MeResponse = {
|
||||
user_id: string
|
||||
username: string
|
||||
groups: ReadonlyArray<string>
|
||||
totp_confirmed: boolean
|
||||
totp_enabled: boolean
|
||||
permissions: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
export type ReauthCredentials = {
|
||||
current_password: string
|
||||
totp_code?: string
|
||||
}
|
||||
|
||||
export type CreateUserRequest = {
|
||||
username: string
|
||||
password: string
|
||||
group_names?: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
export type PatchUserRequest = {
|
||||
group_names?: ReadonlyArray<string>
|
||||
password?: string
|
||||
disable_totp?: boolean
|
||||
}
|
||||
|
||||
export const REAUTH_REQUIRED = 'reauth_required'
|
||||
|
||||
export function isReauthRequiredError(error: unknown): boolean {
|
||||
return error instanceof Error && error.message === REAUTH_REQUIRED
|
||||
}
|
||||
|
||||
export type NetworkPreviewResponse = {
|
||||
@@ -221,13 +247,20 @@ export async function fetchUsers(
|
||||
return (await response.json()) as UsersResponse
|
||||
}
|
||||
|
||||
export async function deleteUser(
|
||||
id: string,
|
||||
export async function createUser(
|
||||
payload: CreateUserRequest,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<UsersResponse> {
|
||||
const response = await apiFetch(
|
||||
`/api/v1/users?id=${encodeURIComponent(id)}`,
|
||||
{ method: 'DELETE' },
|
||||
'/api/v1/users',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
username: payload.username,
|
||||
password: payload.password,
|
||||
group_names: payload.group_names ?? [],
|
||||
}),
|
||||
},
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
@@ -236,6 +269,153 @@ export async function deleteUser(
|
||||
return (await response.json()) as UsersResponse
|
||||
}
|
||||
|
||||
export async function patchUser(
|
||||
id: string,
|
||||
payload: PatchUserRequest,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<UsersResponse> {
|
||||
const response = await apiFetch(
|
||||
`/api/v1/users?id=${encodeURIComponent(id)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as UsersResponse
|
||||
}
|
||||
|
||||
export async function deleteUser(
|
||||
id: string,
|
||||
credentials: ReauthCredentials,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<UsersResponse> {
|
||||
const response = await apiFetch(
|
||||
`/api/v1/users?id=${encodeURIComponent(id)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({
|
||||
current_password: credentials.current_password,
|
||||
totp_code: credentials.totp_code ?? '',
|
||||
}),
|
||||
},
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as UsersResponse
|
||||
}
|
||||
|
||||
export async function reauth(
|
||||
password: string,
|
||||
totpCode = '',
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<void> {
|
||||
const response = await apiFetch(
|
||||
'/api/v1/auth/reauth',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password, totp_code: totpCode }),
|
||||
},
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
}
|
||||
|
||||
export async function changeMyPassword(
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
totpCode = '',
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<void> {
|
||||
const response = await apiFetch(
|
||||
'/api/v1/me/password',
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
totp_code: totpCode,
|
||||
}),
|
||||
},
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
}
|
||||
|
||||
export async function beginMyTOTP(
|
||||
credentials: ReauthCredentials,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<SetupTOTPBeginResponse> {
|
||||
const response = await apiFetch(
|
||||
'/api/v1/me/totp/begin',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
current_password: credentials.current_password,
|
||||
totp_code: credentials.totp_code ?? '',
|
||||
}),
|
||||
},
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as SetupTOTPBeginResponse
|
||||
}
|
||||
|
||||
export async function confirmMyTOTP(
|
||||
secret: string,
|
||||
code: string,
|
||||
credentials: ReauthCredentials,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<void> {
|
||||
const response = await apiFetch(
|
||||
'/api/v1/me/totp/confirm',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
secret,
|
||||
code,
|
||||
current_password: credentials.current_password,
|
||||
totp_code: credentials.totp_code ?? '',
|
||||
}),
|
||||
},
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
}
|
||||
|
||||
export async function disableMyTOTP(
|
||||
credentials: ReauthCredentials,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<void> {
|
||||
const response = await apiFetch(
|
||||
'/api/v1/me/totp/disable',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
current_password: credentials.current_password,
|
||||
totp_code: credentials.totp_code ?? '',
|
||||
}),
|
||||
},
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchSecurity(
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<SecurityResponse> {
|
||||
@@ -424,3 +604,108 @@ export async function fetchMe(
|
||||
}
|
||||
return (await response.json()) as MeResponse
|
||||
}
|
||||
|
||||
export type NodeKind = 'container' | 'vm' | 'docker'
|
||||
|
||||
export type Node = {
|
||||
id: string
|
||||
kind: NodeKind
|
||||
name: string
|
||||
host_ip: string
|
||||
username: string
|
||||
group_name: string
|
||||
public_key: string
|
||||
key_algo: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type NodesResponse = {
|
||||
nodes: ReadonlyArray<Node>
|
||||
}
|
||||
|
||||
export type NodeResponse = {
|
||||
node: Node
|
||||
}
|
||||
|
||||
export type CreateNodeGenerate = {
|
||||
algorithm: 'ed25519' | 'rsa'
|
||||
rsa_bits?: number
|
||||
kdf_rounds?: number
|
||||
}
|
||||
|
||||
export type CreateNodeRequest = {
|
||||
id?: string
|
||||
kind: NodeKind
|
||||
name: string
|
||||
host_ip: string
|
||||
username: string
|
||||
group_name?: string
|
||||
new_group?: { name: string }
|
||||
generate?: CreateNodeGenerate
|
||||
private_key?: string
|
||||
}
|
||||
|
||||
export async function fetchNodes(
|
||||
kind?: NodeKind,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<NodesResponse> {
|
||||
const query = kind ? `?kind=${encodeURIComponent(kind)}` : ''
|
||||
const response = await apiFetch(`/api/v1/nodes${query}`, {}, fetchImpl)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as NodesResponse
|
||||
}
|
||||
|
||||
export async function createNode(
|
||||
payload: CreateNodeRequest,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<NodeResponse> {
|
||||
const response = await apiFetch(
|
||||
'/api/v1/nodes',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as NodeResponse
|
||||
}
|
||||
|
||||
export async function fetchNode(
|
||||
id: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<NodeResponse> {
|
||||
const response = await apiFetch(
|
||||
`/api/v1/nodes/${encodeURIComponent(id)}`,
|
||||
{},
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as NodeResponse
|
||||
}
|
||||
|
||||
export type NodeSSHTestResponse = {
|
||||
ok: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
export async function testNodeSSH(
|
||||
id: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<NodeSSHTestResponse> {
|
||||
const response = await apiFetch(
|
||||
`/api/v1/nodes/${encodeURIComponent(id)}/test-ssh`,
|
||||
{ method: 'POST' },
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as NodeSSHTestResponse
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ describe('pathFor', () => {
|
||||
expect(pathFor('vms')).toBe('/vms')
|
||||
expect(pathFor('docker')).toBe('/docker')
|
||||
expect(pathFor('containers')).toBe('/containers')
|
||||
expect(pathFor('vms', 'overview', 'security')).toBe('/vms/security')
|
||||
expect(pathFor('containers', 'overview', 'add')).toBe('/containers/add')
|
||||
expect(pathFor('docker', 'overview', 'add')).toBe('/docker/add')
|
||||
expect(pathFor('profile')).toBe('/profile')
|
||||
expect(pathFor('setup')).toBe('/setup')
|
||||
expect(pathFor('login')).toBe('/login')
|
||||
@@ -24,6 +27,9 @@ describe('parseLocation', () => {
|
||||
'/vms',
|
||||
'/docker',
|
||||
'/containers',
|
||||
'/vms/security',
|
||||
'/containers/add',
|
||||
'/docker/overview',
|
||||
'/profile',
|
||||
'/setup',
|
||||
'/login',
|
||||
@@ -37,7 +43,9 @@ describe('parseLocation', () => {
|
||||
'/configuration/advanced',
|
||||
]) {
|
||||
const location = parseLocation(path)
|
||||
expect(pathFor(location.section, location.configTab)).toBe(path)
|
||||
expect(
|
||||
pathFor(location.section, location.configTab, location.resourceTab),
|
||||
).toBe(path === '/docker/overview' ? '/docker' : path)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -45,18 +53,22 @@ describe('parseLocation', () => {
|
||||
expect(parseLocation('/nope')).toEqual({
|
||||
section: 'overview',
|
||||
configTab: 'overview',
|
||||
resourceTab: 'overview',
|
||||
})
|
||||
expect(parseLocation('/configuration/unknown')).toEqual({
|
||||
section: 'overview',
|
||||
configTab: 'overview',
|
||||
resourceTab: 'overview',
|
||||
})
|
||||
expect(parseLocation('/vms/extra')).toEqual({
|
||||
section: 'overview',
|
||||
configTab: 'overview',
|
||||
resourceTab: 'overview',
|
||||
})
|
||||
expect(parseLocation('/docker/extra')).toEqual({
|
||||
section: 'overview',
|
||||
configTab: 'overview',
|
||||
resourceTab: 'overview',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -64,6 +76,12 @@ describe('parseLocation', () => {
|
||||
expect(parseLocation('/configuration/groups/')).toEqual({
|
||||
section: 'configuration',
|
||||
configTab: 'groups',
|
||||
resourceTab: 'overview',
|
||||
})
|
||||
expect(parseLocation('/containers/add/')).toEqual({
|
||||
section: 'containers',
|
||||
configTab: 'overview',
|
||||
resourceTab: 'add',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+59
-28
@@ -18,9 +18,14 @@ export type ConfigTabId =
|
||||
| 'network'
|
||||
| 'advanced'
|
||||
|
||||
export type ResourceTabId = 'overview' | 'security' | 'add'
|
||||
|
||||
export type ResourceSectionId = 'containers' | 'vms' | 'docker'
|
||||
|
||||
export type AppLocation = {
|
||||
section: SectionId
|
||||
configTab: ConfigTabId
|
||||
resourceTab: ResourceTabId
|
||||
}
|
||||
|
||||
const CONFIG_TAB_IDS: ReadonlySet<string> = new Set([
|
||||
@@ -34,16 +39,37 @@ const CONFIG_TAB_IDS: ReadonlySet<string> = new Set([
|
||||
'advanced',
|
||||
])
|
||||
|
||||
const RESOURCE_TAB_IDS: ReadonlySet<string> = new Set([
|
||||
'overview',
|
||||
'security',
|
||||
'add',
|
||||
])
|
||||
|
||||
const RESOURCE_SECTION_IDS: ReadonlySet<string> = new Set([
|
||||
'containers',
|
||||
'vms',
|
||||
'docker',
|
||||
])
|
||||
|
||||
const DEFAULT_LOCATION: AppLocation = {
|
||||
section: 'overview',
|
||||
configTab: 'overview',
|
||||
resourceTab: 'overview',
|
||||
}
|
||||
|
||||
function isConfigTabId(value: string): value is ConfigTabId {
|
||||
return CONFIG_TAB_IDS.has(value)
|
||||
}
|
||||
|
||||
/** Parse a pathname into section + config tab. Unknown paths fall back to Overview. */
|
||||
function isResourceTabId(value: string): value is ResourceTabId {
|
||||
return RESOURCE_TAB_IDS.has(value)
|
||||
}
|
||||
|
||||
export function isResourceSectionId(value: SectionId): value is ResourceSectionId {
|
||||
return RESOURCE_SECTION_IDS.has(value)
|
||||
}
|
||||
|
||||
/** Parse a pathname into section + config/resource tab. Unknown paths fall back to Overview. */
|
||||
export function parseLocation(pathname: string): AppLocation {
|
||||
const normalized = pathname.replace(/\/+$/, '') || '/'
|
||||
const segments = normalized.split('/').filter(Boolean)
|
||||
@@ -55,45 +81,53 @@ export function parseLocation(pathname: string): AppLocation {
|
||||
const [first, second] = segments
|
||||
|
||||
if (first === 'setup' && segments.length === 1) {
|
||||
return { section: 'setup', configTab: 'overview' }
|
||||
return { section: 'setup', configTab: 'overview', resourceTab: 'overview' }
|
||||
}
|
||||
|
||||
if (first === 'login' && segments.length === 1) {
|
||||
return { section: 'login', configTab: 'overview' }
|
||||
}
|
||||
|
||||
if (first === 'vms' && segments.length === 1) {
|
||||
return { section: 'vms', configTab: 'overview' }
|
||||
}
|
||||
|
||||
if (first === 'docker' && segments.length === 1) {
|
||||
return { section: 'docker', configTab: 'overview' }
|
||||
}
|
||||
|
||||
if (first === 'containers' && segments.length === 1) {
|
||||
return { section: 'containers', configTab: 'overview' }
|
||||
return { section: 'login', configTab: 'overview', resourceTab: 'overview' }
|
||||
}
|
||||
|
||||
if (first === 'profile' && segments.length === 1) {
|
||||
return { section: 'profile', configTab: 'overview' }
|
||||
return { section: 'profile', configTab: 'overview', resourceTab: 'overview' }
|
||||
}
|
||||
|
||||
if (RESOURCE_SECTION_IDS.has(first)) {
|
||||
const section = first as ResourceSectionId
|
||||
if (segments.length === 1) {
|
||||
return { section, configTab: 'overview', resourceTab: 'overview' }
|
||||
}
|
||||
if (segments.length === 2 && isResourceTabId(second)) {
|
||||
return { section, configTab: 'overview', resourceTab: second }
|
||||
}
|
||||
return { ...DEFAULT_LOCATION }
|
||||
}
|
||||
|
||||
if (first === 'configuration') {
|
||||
if (segments.length === 1) {
|
||||
return { section: 'configuration', configTab: 'overview' }
|
||||
return {
|
||||
section: 'configuration',
|
||||
configTab: 'overview',
|
||||
resourceTab: 'overview',
|
||||
}
|
||||
}
|
||||
if (segments.length === 2 && isConfigTabId(second)) {
|
||||
return { section: 'configuration', configTab: second }
|
||||
return {
|
||||
section: 'configuration',
|
||||
configTab: second,
|
||||
resourceTab: 'overview',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ...DEFAULT_LOCATION }
|
||||
}
|
||||
|
||||
/** Build the pathname for a section (and optional config tab). */
|
||||
/** Build the pathname for a section (and optional config or resource tab). */
|
||||
export function pathFor(
|
||||
section: SectionId,
|
||||
configTab: ConfigTabId = 'overview',
|
||||
resourceTab: ResourceTabId = 'overview',
|
||||
): string {
|
||||
if (section === 'overview') {
|
||||
return '/'
|
||||
@@ -104,18 +138,15 @@ export function pathFor(
|
||||
if (section === 'login') {
|
||||
return '/login'
|
||||
}
|
||||
if (section === 'vms') {
|
||||
return '/vms'
|
||||
}
|
||||
if (section === 'docker') {
|
||||
return '/docker'
|
||||
}
|
||||
if (section === 'containers') {
|
||||
return '/containers'
|
||||
}
|
||||
if (section === 'profile') {
|
||||
return '/profile'
|
||||
}
|
||||
if (isResourceSectionId(section)) {
|
||||
if (resourceTab === 'overview') {
|
||||
return `/${section}`
|
||||
}
|
||||
return `/${section}/${resourceTab}`
|
||||
}
|
||||
if (configTab === 'overview') {
|
||||
return '/configuration'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user