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.