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

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