Add SSH-managed node registry with connection testing and reauth.
Register hosts under Containers/VMs/Docker with encrypted key storage, and require re-authentication for sensitive account changes.
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||
)
|
||||
|
||||
var errUsersManageRequired = errors.New("permission users.manage required")
|
||||
var errNodesReadRequired = errors.New("permission nodes.read required")
|
||||
var errNodesExecRequired = errors.New("permission nodes.exec required")
|
||||
|
||||
// permissionsForUser returns the union of permissions from the user's groups.
|
||||
func permissionsForUser(user settings.UserCredential, groups []settings.Group) map[string]struct{} {
|
||||
groupByName := make(map[string]settings.Group, len(groups))
|
||||
for _, group := range groups {
|
||||
groupByName[group.Name] = group
|
||||
}
|
||||
|
||||
result := make(map[string]struct{})
|
||||
for _, groupName := range user.GroupNames {
|
||||
group, ok := groupByName[groupName]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, permission := range group.Permissions {
|
||||
result[permission] = struct{}{}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func userHasPermission(user settings.UserCredential, groups []settings.Group, permission string) bool {
|
||||
_, ok := permissionsForUser(user, groups)[permission]
|
||||
return ok
|
||||
}
|
||||
|
||||
func permissionList(user settings.UserCredential, groups []settings.Group) []string {
|
||||
set := permissionsForUser(user, groups)
|
||||
ordered := allPermissionList()
|
||||
result := make([]string, 0, len(set))
|
||||
for _, permission := range ordered {
|
||||
if _, ok := set[permission]; ok {
|
||||
result = append(result, permission)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (app *App) authorizeUsersWrite(request *http.Request) error {
|
||||
user, ok := UserFromContext(request.Context())
|
||||
if !ok {
|
||||
return errors.New("authentication required")
|
||||
}
|
||||
|
||||
payload, err := loadSettingsOrDefault(app.ConfigDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !userHasPermission(user, payload.Groups, "users.manage") {
|
||||
return errUsersManageRequired
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *App) authorizeNodesRead(request *http.Request) error {
|
||||
user, ok := UserFromContext(request.Context())
|
||||
if !ok {
|
||||
return errors.New("authentication required")
|
||||
}
|
||||
|
||||
payload, err := loadSettingsOrDefault(app.ConfigDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !userHasPermission(user, payload.Groups, "nodes.read") {
|
||||
return errNodesReadRequired
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *App) authorizeNodesExec(request *http.Request) error {
|
||||
user, ok := UserFromContext(request.Context())
|
||||
if !ok {
|
||||
return errors.New("authentication required")
|
||||
}
|
||||
|
||||
payload, err := loadSettingsOrDefault(app.ConfigDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !userHasPermission(user, payload.Groups, "nodes.exec") {
|
||||
return errNodesExecRequired
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -158,10 +158,8 @@ func validateGroup(group settings.Group) error {
|
||||
return errors.New("group.scope_name is required")
|
||||
}
|
||||
|
||||
if len(group.Permissions) == 0 {
|
||||
return errors.New("group.permissions must be non-empty")
|
||||
}
|
||||
|
||||
// Empty permissions are allowed so a newly scoped node group can be
|
||||
// created first and roles assigned later in Configuration → Groups.
|
||||
for _, permission := range group.Permissions {
|
||||
if strings.TrimSpace(permission) == "" {
|
||||
return errors.New("permission strings must be non-empty")
|
||||
|
||||
@@ -18,7 +18,7 @@ type apiErrorResponseTest struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func TestGroupsGetEmptyWhenNoGroups(t *testing.T) {
|
||||
func TestGroupsGetIncludesAdministratorsFromSetup(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
router := NewRouter(configDir)
|
||||
@@ -36,8 +36,8 @@ func TestGroupsGetEmptyWhenNoGroups(t *testing.T) {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
|
||||
if payload.Groups == nil || len(payload.Groups) != 0 {
|
||||
t.Fatalf("expected empty groups list, got %#v", payload.Groups)
|
||||
if len(payload.Groups) != 1 || payload.Groups[0].Name != settings.AdministratorsGroupName {
|
||||
t.Fatalf("expected Administrators group from seed, got %#v", payload.Groups)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,14 +118,20 @@ func TestGroupsUpsertUpsertsByName(t *testing.T) {
|
||||
if err := json.NewDecoder(getRec.Body).Decode(&getPayload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if len(getPayload.Groups) != 1 {
|
||||
t.Fatalf("expected 1 group, got %d", len(getPayload.Groups))
|
||||
if len(getPayload.Groups) != 2 {
|
||||
t.Fatalf("expected 2 groups (Administrators + Admins), got %d", len(getPayload.Groups))
|
||||
}
|
||||
if getPayload.Groups[0].Name != "Admins" {
|
||||
t.Fatalf("expected group name %q, got %q", "Admins", getPayload.Groups[0].Name)
|
||||
var admins *settings.Group
|
||||
for index := range getPayload.Groups {
|
||||
if getPayload.Groups[index].Name == "Admins" {
|
||||
admins = &getPayload.Groups[index]
|
||||
}
|
||||
}
|
||||
if getPayload.Groups[0].ScopeName != "node-2" {
|
||||
t.Fatalf("expected scope_name %q, got %q", "node-2", getPayload.Groups[0].ScopeName)
|
||||
if admins == nil {
|
||||
t.Fatal("expected Admins group")
|
||||
}
|
||||
if admins.ScopeName != "node-2" {
|
||||
t.Fatalf("expected scope_name %q, got %q", "node-2", admins.ScopeName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,8 +173,8 @@ func TestGroupsDeleteRemovesByName(t *testing.T) {
|
||||
if err := json.NewDecoder(getRec.Body).Decode(&getPayload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if len(getPayload.Groups) != 0 {
|
||||
t.Fatalf("expected empty groups, got %#v", getPayload.Groups)
|
||||
if len(getPayload.Groups) != 1 || getPayload.Groups[0].Name != settings.AdministratorsGroupName {
|
||||
t.Fatalf("expected only Administrators remaining, got %#v", getPayload.Groups)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||
)
|
||||
|
||||
type changePasswordRequest struct {
|
||||
CurrentPassword string `json:"current_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
TOTPCode string `json:"totp_code"`
|
||||
}
|
||||
|
||||
type totpBeginRequest struct {
|
||||
CurrentPassword string `json:"current_password"`
|
||||
TOTPCode string `json:"totp_code"`
|
||||
}
|
||||
|
||||
type totpConfirmRequest struct {
|
||||
Secret string `json:"secret"`
|
||||
Code string `json:"code"`
|
||||
CurrentPassword string `json:"current_password"`
|
||||
TOTPCode string `json:"totp_code"`
|
||||
}
|
||||
|
||||
type totpDisableRequest struct {
|
||||
CurrentPassword string `json:"current_password"`
|
||||
TOTPCode string `json:"totp_code"`
|
||||
}
|
||||
|
||||
func (app *App) reauthHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
user, ok := UserFromContext(request.Context())
|
||||
if !ok {
|
||||
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "not authenticated"})
|
||||
return
|
||||
}
|
||||
session, ok := SessionFromContext(request.Context())
|
||||
if !ok {
|
||||
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var payload reauthRequest
|
||||
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
|
||||
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
security := effectiveSecurity(settingsPayload.Security)
|
||||
|
||||
if err := verifyActorCredentials(user, payload.Password, payload.TOTPCode, security); err != nil {
|
||||
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if app.Sessions == nil {
|
||||
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: "sessions unavailable"})
|
||||
return
|
||||
}
|
||||
if err := app.Sessions.MarkReauth(session.ID); err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (app *App) mePasswordHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
user, ok := UserFromContext(request.Context())
|
||||
if !ok {
|
||||
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "not authenticated"})
|
||||
return
|
||||
}
|
||||
if err := app.requireKey(); err != nil {
|
||||
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var payload changePasswordRequest
|
||||
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := app.requireInlineReauth(request, payload.CurrentPassword, payload.TOTPCode); err != nil {
|
||||
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := auth.ValidatePassword(payload.NewPassword, user.Username); err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(payload.NewPassword)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
store, err := settings.LoadPasswords(app.ConfigDir, app.Key)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
found := false
|
||||
now := time.Now().UTC()
|
||||
for index := range store.Users {
|
||||
if store.Users[index].ID != user.ID {
|
||||
continue
|
||||
}
|
||||
store.Users[index].PasswordHash = hash
|
||||
store.Users[index].PasswordChangedAt = now
|
||||
found = true
|
||||
break
|
||||
}
|
||||
if !found {
|
||||
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "user not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := settings.SavePasswords(app.ConfigDir, store, app.Key); err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if app.Sessions != nil {
|
||||
_ = app.Sessions.InvalidateUserSessions(user.ID)
|
||||
_ = app.Sessions.DestroySession(writer, request)
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (app *App) meTOTPBeginHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
user, ok := UserFromContext(request.Context())
|
||||
if !ok {
|
||||
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var payload totpBeginRequest
|
||||
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := app.requireInlineReauth(request, payload.CurrentPassword, payload.TOTPCode); err != nil {
|
||||
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
security := effectiveSecurity(settingsPayload.Security)
|
||||
if !security.TotpEnabled {
|
||||
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "TOTP is not enabled on this server"})
|
||||
return
|
||||
}
|
||||
if user.TOTPConfirmed && user.TOTPSecret != "" {
|
||||
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "TOTP is already enabled"})
|
||||
return
|
||||
}
|
||||
|
||||
key, err := auth.GenerateTOTPSecret(user.Username)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, setupTOTPBeginResponse{
|
||||
Secret: key.Secret(),
|
||||
OTPAuthURL: key.URL(),
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) meTOTPConfirmHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
user, ok := UserFromContext(request.Context())
|
||||
if !ok {
|
||||
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "not authenticated"})
|
||||
return
|
||||
}
|
||||
if err := app.requireKey(); err != nil {
|
||||
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var payload totpConfirmRequest
|
||||
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := app.requireInlineReauth(request, payload.CurrentPassword, payload.TOTPCode); err != nil {
|
||||
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
security := effectiveSecurity(settingsPayload.Security)
|
||||
if !security.TotpEnabled {
|
||||
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "TOTP is not enabled on this server"})
|
||||
return
|
||||
}
|
||||
|
||||
secret := strings.TrimSpace(payload.Secret)
|
||||
if secret == "" {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "missing secret"})
|
||||
return
|
||||
}
|
||||
if !auth.VerifyTOTPCode(secret, payload.Code) {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid TOTP code"})
|
||||
return
|
||||
}
|
||||
|
||||
store, err := settings.LoadPasswords(app.ConfigDir, app.Key)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
found := false
|
||||
for index := range store.Users {
|
||||
if store.Users[index].ID != user.ID {
|
||||
continue
|
||||
}
|
||||
store.Users[index].TOTPSecret = secret
|
||||
store.Users[index].TOTPConfirmed = true
|
||||
found = true
|
||||
break
|
||||
}
|
||||
if !found {
|
||||
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "user not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := settings.SavePasswords(app.ConfigDir, store, app.Key); err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (app *App) meTOTPDisableHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
user, ok := UserFromContext(request.Context())
|
||||
if !ok {
|
||||
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "not authenticated"})
|
||||
return
|
||||
}
|
||||
if err := app.requireKey(); err != nil {
|
||||
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var payload totpDisableRequest
|
||||
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
|
||||
if !user.TOTPConfirmed || user.TOTPSecret == "" {
|
||||
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "TOTP is not enabled"})
|
||||
return
|
||||
}
|
||||
|
||||
// Always require password; require current TOTP code when enrolled.
|
||||
okPassword, err := auth.VerifyPassword(payload.CurrentPassword, user.PasswordHash)
|
||||
if err != nil || !okPassword {
|
||||
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid credentials"})
|
||||
return
|
||||
}
|
||||
if !auth.VerifyTOTPCode(user.TOTPSecret, payload.TOTPCode) {
|
||||
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid or missing TOTP code"})
|
||||
return
|
||||
}
|
||||
|
||||
store, err := settings.LoadPasswords(app.ConfigDir, app.Key)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
found := false
|
||||
for index := range store.Users {
|
||||
if store.Users[index].ID != user.ID {
|
||||
continue
|
||||
}
|
||||
store.Users[index].TOTPSecret = ""
|
||||
store.Users[index].TOTPConfirmed = false
|
||||
found = true
|
||||
break
|
||||
}
|
||||
if !found {
|
||||
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "user not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := settings.SavePasswords(app.ConfigDir, store, app.Key); err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
@@ -136,7 +136,7 @@ func (app *App) withCORS(next http.Handler) http.Handler {
|
||||
writer.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
writer.Header().Set("Vary", "Origin")
|
||||
}
|
||||
writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
writer.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
|
||||
if request.Method == http.MethodOptions {
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||
)
|
||||
|
||||
type nodesResponse struct {
|
||||
Nodes []settings.Node `json:"nodes"`
|
||||
}
|
||||
|
||||
type nodeResponse struct {
|
||||
Node settings.Node `json:"node"`
|
||||
}
|
||||
|
||||
type createNodeGenerate struct {
|
||||
Algorithm string `json:"algorithm"`
|
||||
RSABits int `json:"rsa_bits"`
|
||||
KDFRounds int `json:"kdf_rounds"`
|
||||
}
|
||||
|
||||
type createNodeNewGroup struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type createNodeRequest struct {
|
||||
ID string `json:"id"`
|
||||
Kind settings.NodeKind `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
HostIP string `json:"host_ip"`
|
||||
Username string `json:"username"`
|
||||
GroupName string `json:"group_name"`
|
||||
NewGroup *createNodeNewGroup `json:"new_group"`
|
||||
Generate *createNodeGenerate `json:"generate"`
|
||||
PrivateKey string `json:"private_key"`
|
||||
}
|
||||
|
||||
func (app *App) nodesListHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := app.authorizeNodesRead(request); err != nil {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := UserFromContext(request.Context())
|
||||
if !ok {
|
||||
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "authentication required"})
|
||||
return
|
||||
}
|
||||
|
||||
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
store, err := settings.LoadNodesOrEmpty(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
kindFilter := strings.TrimSpace(request.URL.Query().Get("kind"))
|
||||
filtered := make([]settings.Node, 0, len(store.Nodes))
|
||||
for _, node := range store.Nodes {
|
||||
if kindFilter != "" && string(node.Kind) != kindFilter {
|
||||
continue
|
||||
}
|
||||
if !userCanAccessNode(user, settingsPayload.Groups, node) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, node)
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, nodesResponse{Nodes: filtered})
|
||||
}
|
||||
|
||||
func (app *App) nodesGetHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := app.authorizeNodesRead(request); err != nil {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := UserFromContext(request.Context())
|
||||
if !ok {
|
||||
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "authentication required"})
|
||||
return
|
||||
}
|
||||
|
||||
nodeID := strings.TrimSpace(request.PathValue("id"))
|
||||
if nodeID == "" {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "missing node id"})
|
||||
return
|
||||
}
|
||||
|
||||
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
store, err := settings.LoadNodesOrEmpty(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
for _, node := range store.Nodes {
|
||||
if node.ID != nodeID {
|
||||
continue
|
||||
}
|
||||
if !userCanAccessNode(user, settingsPayload.Groups, node) {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: "permission denied"})
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, nodeResponse{Node: node})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "node not found"})
|
||||
}
|
||||
|
||||
type nodeSSHTestResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (app *App) nodesTestSSHHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := app.authorizeNodesExec(request); err != nil {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if err := app.requireKey(); err != nil {
|
||||
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := UserFromContext(request.Context())
|
||||
if !ok {
|
||||
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "authentication required"})
|
||||
return
|
||||
}
|
||||
|
||||
nodeID := strings.TrimSpace(request.PathValue("id"))
|
||||
if nodeID == "" {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "missing node id"})
|
||||
return
|
||||
}
|
||||
|
||||
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
store, err := settings.LoadNodesOrEmpty(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var node settings.Node
|
||||
found := false
|
||||
for _, candidate := range store.Nodes {
|
||||
if candidate.ID != nodeID {
|
||||
continue
|
||||
}
|
||||
node = candidate
|
||||
found = true
|
||||
break
|
||||
}
|
||||
if !found {
|
||||
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "node not found"})
|
||||
return
|
||||
}
|
||||
if !userCanAccessNode(user, settingsPayload.Groups, node) {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: "permission denied"})
|
||||
return
|
||||
}
|
||||
|
||||
keyStore, err := settings.LoadNodeKeysOrEmpty(app.ConfigDir, app.Key)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var keyEntry settings.NodeKeyEntry
|
||||
keyFound := false
|
||||
for _, entry := range keyStore.Keys {
|
||||
if entry.NodeID == node.ID {
|
||||
keyEntry = entry
|
||||
keyFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !keyFound {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{
|
||||
Error: "private key not found for node",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := auth.TestSSHConnection(node.HostIP, node.Username, keyEntry.PrivateKey, keyEntry.Passphrase); err != nil {
|
||||
writeJSON(writer, http.StatusOK, nodeSSHTestResponse{
|
||||
OK: false,
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, nodeSSHTestResponse{
|
||||
OK: true,
|
||||
Message: "SSH connection succeeded",
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) nodesCreateHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := app.authorizeNodesExec(request); err != nil {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if err := app.requireKey(); err != nil {
|
||||
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var payload createNodeRequest
|
||||
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
|
||||
node, keyEntry, err := buildNodeFromCreateRequest(payload)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if payload.NewGroup != nil {
|
||||
groupName := strings.TrimSpace(payload.NewGroup.Name)
|
||||
if groupName == "" {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "new_group.name is required"})
|
||||
return
|
||||
}
|
||||
for _, existing := range settingsPayload.Groups {
|
||||
if existing.Name == groupName {
|
||||
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "group already exists"})
|
||||
return
|
||||
}
|
||||
}
|
||||
newGroup := settings.Group{
|
||||
Name: groupName,
|
||||
ScopeKind: settings.GroupScopeNode,
|
||||
ScopeName: node.ID,
|
||||
Permissions: []string{},
|
||||
}
|
||||
settingsPayload.Groups = append(settingsPayload.Groups, newGroup)
|
||||
node.GroupName = groupName
|
||||
} else {
|
||||
groupName := strings.TrimSpace(payload.GroupName)
|
||||
if groupName == "" {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "group_name is required"})
|
||||
return
|
||||
}
|
||||
found := false
|
||||
for _, existing := range settingsPayload.Groups {
|
||||
if existing.Name == groupName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "group not found"})
|
||||
return
|
||||
}
|
||||
node.GroupName = groupName
|
||||
}
|
||||
|
||||
store, err := settings.LoadNodesOrEmpty(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
for _, existing := range store.Nodes {
|
||||
if existing.ID == node.ID {
|
||||
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "node id already exists"})
|
||||
return
|
||||
}
|
||||
if existing.Name == node.Name && existing.Kind == node.Kind {
|
||||
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "node name already exists for this kind"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
keyStore, err := settings.LoadNodeKeysOrEmpty(app.ConfigDir, app.Key)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
store.Nodes = append(store.Nodes, node)
|
||||
keyStore.Keys = append(keyStore.Keys, keyEntry)
|
||||
|
||||
if payload.NewGroup != nil {
|
||||
if err := settings.SaveSettings(app.ConfigDir, settingsPayload); err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := settings.SaveNodes(app.ConfigDir, store); err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if err := settings.SaveNodeKeys(app.ConfigDir, keyStore, app.Key); err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusCreated, nodeResponse{Node: node})
|
||||
}
|
||||
|
||||
func buildNodeFromCreateRequest(payload createNodeRequest) (settings.Node, settings.NodeKeyEntry, error) {
|
||||
kind := payload.Kind
|
||||
switch kind {
|
||||
case settings.NodeKindContainer, settings.NodeKindVM, settings.NodeKindDocker:
|
||||
// ok
|
||||
default:
|
||||
return settings.Node{}, settings.NodeKeyEntry{}, errors.New("kind must be container, vm, or docker")
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(payload.Name)
|
||||
if name == "" {
|
||||
return settings.Node{}, settings.NodeKeyEntry{}, errors.New("name is required")
|
||||
}
|
||||
|
||||
hostIP := strings.TrimSpace(payload.HostIP)
|
||||
if hostIP == "" {
|
||||
return settings.Node{}, settings.NodeKeyEntry{}, errors.New("host_ip is required")
|
||||
}
|
||||
if parsed := net.ParseIP(hostIP); parsed == nil {
|
||||
return settings.Node{}, settings.NodeKeyEntry{}, errors.New("host_ip must be a valid IPv4 or IPv6 address")
|
||||
}
|
||||
|
||||
username := strings.TrimSpace(payload.Username)
|
||||
if username == "" {
|
||||
username = auth.DefaultSSHUsername
|
||||
}
|
||||
|
||||
nodeID := strings.TrimSpace(payload.ID)
|
||||
if nodeID == "" {
|
||||
generatedID, err := auth.NewUUID()
|
||||
if err != nil {
|
||||
return settings.Node{}, settings.NodeKeyEntry{}, err
|
||||
}
|
||||
nodeID = generatedID
|
||||
} else if !isUUID(nodeID) {
|
||||
return settings.Node{}, settings.NodeKeyEntry{}, errors.New("id must be a UUID")
|
||||
}
|
||||
|
||||
var generated auth.GeneratedSSHKey
|
||||
var err error
|
||||
switch {
|
||||
case payload.Generate != nil && strings.TrimSpace(payload.PrivateKey) != "":
|
||||
return settings.Node{}, settings.NodeKeyEntry{}, errors.New("provide either generate or private_key, not both")
|
||||
case payload.Generate != nil:
|
||||
generated, err = auth.GenerateSSHKey(
|
||||
payload.Generate.Algorithm,
|
||||
payload.Generate.RSABits,
|
||||
payload.Generate.KDFRounds,
|
||||
)
|
||||
case strings.TrimSpace(payload.PrivateKey) != "":
|
||||
generated, err = auth.ParseSSHPrivateKey(payload.PrivateKey)
|
||||
default:
|
||||
return settings.Node{}, settings.NodeKeyEntry{}, errors.New("generate or private_key is required")
|
||||
}
|
||||
if err != nil {
|
||||
return settings.Node{}, settings.NodeKeyEntry{}, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
node := settings.Node{
|
||||
ID: nodeID,
|
||||
Kind: kind,
|
||||
Name: name,
|
||||
HostIP: hostIP,
|
||||
Username: username,
|
||||
PublicKey: generated.PublicKey,
|
||||
KeyAlgo: generated.Algorithm,
|
||||
CreatedAt: now,
|
||||
}
|
||||
keyEntry := settings.NodeKeyEntry{
|
||||
NodeID: nodeID,
|
||||
PrivateKey: generated.PrivateKeyPEM,
|
||||
Algorithm: generated.Algorithm,
|
||||
RSABits: generated.RSABits,
|
||||
KDFRounds: generated.KDFRounds,
|
||||
}
|
||||
return node, keyEntry, nil
|
||||
}
|
||||
|
||||
func userCanAccessNode(user settings.UserCredential, groups []settings.Group, node settings.Node) bool {
|
||||
for _, groupName := range user.GroupNames {
|
||||
if groupName == settings.AdministratorsGroupName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, groupName := range user.GroupNames {
|
||||
if groupName == node.GroupName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Users with nodes.read via any group can list nodes they belong to above;
|
||||
// Administrators already returned. Also allow if any of user's groups has
|
||||
// scope_kind=node and scope_name matching this node id.
|
||||
groupByName := make(map[string]settings.Group, len(groups))
|
||||
for _, group := range groups {
|
||||
groupByName[group.Name] = group
|
||||
}
|
||||
for _, groupName := range user.GroupNames {
|
||||
group, ok := groupByName[groupName]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if group.ScopeKind == settings.GroupScopeNode && group.ScopeName == node.ID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isUUID(value string) bool {
|
||||
if len(value) != 36 {
|
||||
return false
|
||||
}
|
||||
for index, char := range value {
|
||||
switch index {
|
||||
case 8, 13, 18, 23:
|
||||
if char != '-' {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
if (char < '0' || char > '9') && (char < 'a' || char > 'f') && (char < 'A' || char > 'F') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||
)
|
||||
|
||||
func TestNodesCreateGeneratesEd25519AndStoresEncryptedKey(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
router := NewRouter(configDir)
|
||||
|
||||
body := map[string]any{
|
||||
"id": "11111111-2222-4333-8444-555555555555",
|
||||
"kind": "container",
|
||||
"name": "ct-alpha",
|
||||
"host_ip": "192.168.10.20",
|
||||
"username": "clustercanvas",
|
||||
"group_name": settings.AdministratorsGroupName,
|
||||
"generate": map[string]any{
|
||||
"algorithm": "ed25519",
|
||||
"kdf_rounds": 100,
|
||||
},
|
||||
}
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
|
||||
request := withSession(
|
||||
httptest.NewRequest(http.MethodPost, "/api/v1/nodes", bytes.NewReader(payload)),
|
||||
cookie,
|
||||
)
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
|
||||
var response nodeResponse
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if response.Node.ID != "11111111-2222-4333-8444-555555555555" {
|
||||
t.Fatalf("id = %q", response.Node.ID)
|
||||
}
|
||||
if response.Node.PublicKey == "" {
|
||||
t.Fatal("expected public key")
|
||||
}
|
||||
if strings.Contains(recorder.Body.String(), "BEGIN OPENSSH PRIVATE KEY") {
|
||||
t.Fatal("private key must not appear in API response")
|
||||
}
|
||||
|
||||
keyBytes, err := settings.KeyFromEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("KeyFromEnv: %v", err)
|
||||
}
|
||||
keyStore, err := settings.LoadNodeKeys(configDir, keyBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadNodeKeys: %v", err)
|
||||
}
|
||||
if len(keyStore.Keys) != 1 {
|
||||
t.Fatalf("keys = %d", len(keyStore.Keys))
|
||||
}
|
||||
if keyStore.Keys[0].NodeID != response.Node.ID {
|
||||
t.Fatalf("key node id = %q", keyStore.Keys[0].NodeID)
|
||||
}
|
||||
if !strings.Contains(keyStore.Keys[0].PrivateKey, "BEGIN OPENSSH PRIVATE KEY") {
|
||||
t.Fatal("expected openssh private key in encrypted store")
|
||||
}
|
||||
if keyStore.Keys[0].KDFRounds != 100 {
|
||||
t.Fatalf("kdf rounds = %d", keyStore.Keys[0].KDFRounds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodesCreateRejectsWithoutNodesExec(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
_ = seedCompletedSetup(t, configDir)
|
||||
|
||||
keyBytes, err := settings.KeyFromEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("KeyFromEnv: %v", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
passwordHash, err := auth.HashPassword("correct horse battery staple extra")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword: %v", err)
|
||||
}
|
||||
|
||||
payload := settings.Settings{
|
||||
SetupCompleted: true,
|
||||
Groups: []settings.Group{
|
||||
{
|
||||
Name: settings.AdministratorsGroupName,
|
||||
ScopeKind: settings.GroupScopeGroup,
|
||||
ScopeName: "Admin Group",
|
||||
Permissions: allPermissionList(),
|
||||
},
|
||||
{
|
||||
Name: "Readers",
|
||||
ScopeKind: settings.GroupScopeGroup,
|
||||
ScopeName: "Readers",
|
||||
Permissions: []string{"nodes.read"},
|
||||
},
|
||||
},
|
||||
Security: settings.DefaultSecuritySettings(),
|
||||
Network: settings.DefaultNetworkSettings(),
|
||||
}
|
||||
if err := settings.SaveSettings(configDir, payload); err != nil {
|
||||
t.Fatalf("SaveSettings: %v", err)
|
||||
}
|
||||
if err := settings.SavePasswords(configDir, settings.PasswordStore{
|
||||
Users: []settings.UserCredential{
|
||||
{
|
||||
ID: "admin-id",
|
||||
Username: "Admin",
|
||||
PasswordHash: passwordHash,
|
||||
Enabled: true,
|
||||
GroupNames: []string{settings.AdministratorsGroupName},
|
||||
CreatedAt: now,
|
||||
PasswordChangedAt: now,
|
||||
},
|
||||
{
|
||||
ID: "reader-id",
|
||||
Username: "reader",
|
||||
PasswordHash: passwordHash,
|
||||
Enabled: true,
|
||||
GroupNames: []string{"Readers"},
|
||||
CreatedAt: now,
|
||||
PasswordChangedAt: now,
|
||||
},
|
||||
},
|
||||
}, keyBytes); err != nil {
|
||||
t.Fatalf("SavePasswords: %v", err)
|
||||
}
|
||||
|
||||
router := NewRouter(configDir)
|
||||
loginRecorder := httptest.NewRecorder()
|
||||
loginRequest := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/auth/login",
|
||||
bytes.NewReader([]byte(`{"username":"reader","password":"correct horse battery staple extra"}`)),
|
||||
)
|
||||
router.ServeHTTP(loginRecorder, loginRequest)
|
||||
if loginRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("login status = %d body=%s", loginRecorder.Code, loginRecorder.Body.String())
|
||||
}
|
||||
var readerCookie *http.Cookie
|
||||
for _, candidate := range loginRecorder.Result().Cookies() {
|
||||
if candidate.Name == auth.SessionCookieName {
|
||||
readerCookie = candidate
|
||||
}
|
||||
}
|
||||
if readerCookie == nil {
|
||||
t.Fatal("missing session cookie")
|
||||
}
|
||||
|
||||
createBody := []byte(`{
|
||||
"kind":"container",
|
||||
"name":"ct-beta",
|
||||
"host_ip":"10.0.0.2",
|
||||
"group_name":"Readers",
|
||||
"generate":{"algorithm":"ed25519"}
|
||||
}`)
|
||||
request := withSession(
|
||||
httptest.NewRequest(http.MethodPost, "/api/v1/nodes", bytes.NewReader(createBody)),
|
||||
readerCookie,
|
||||
)
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodesCreateRSAAndListByKind(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
router := NewRouter(configDir)
|
||||
|
||||
body := []byte(`{
|
||||
"kind":"vm",
|
||||
"name":"vm-one",
|
||||
"host_ip":"10.0.0.8",
|
||||
"group_name":"Administrators",
|
||||
"generate":{"algorithm":"rsa","rsa_bits":2048}
|
||||
}`)
|
||||
request := withSession(
|
||||
httptest.NewRequest(http.MethodPost, "/api/v1/nodes", bytes.NewReader(body)),
|
||||
cookie,
|
||||
)
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusCreated {
|
||||
t.Fatalf("create status = %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
|
||||
listRequest := withSession(
|
||||
httptest.NewRequest(http.MethodGet, "/api/v1/nodes?kind=vm", nil),
|
||||
cookie,
|
||||
)
|
||||
listRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(listRecorder, listRequest)
|
||||
if listRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("list status = %d", listRecorder.Code)
|
||||
}
|
||||
var list nodesResponse
|
||||
if err := json.Unmarshal(listRecorder.Body.Bytes(), &list); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(list.Nodes) != 1 || list.Nodes[0].Name != "vm-one" {
|
||||
t.Fatalf("nodes = %+v", list.Nodes)
|
||||
}
|
||||
if list.Nodes[0].KeyAlgo != "rsa" {
|
||||
t.Fatalf("key algo = %q", list.Nodes[0].KeyAlgo)
|
||||
}
|
||||
|
||||
emptyRequest := withSession(
|
||||
httptest.NewRequest(http.MethodGet, "/api/v1/nodes?kind=docker", nil),
|
||||
cookie,
|
||||
)
|
||||
emptyRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(emptyRecorder, emptyRequest)
|
||||
var emptyList nodesResponse
|
||||
if err := json.Unmarshal(emptyRecorder.Body.Bytes(), &emptyList); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(emptyList.Nodes) != 0 {
|
||||
t.Fatalf("expected no docker nodes, got %d", len(emptyList.Nodes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodesCreateWithNewGroup(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
router := NewRouter(configDir)
|
||||
|
||||
body := []byte(`{
|
||||
"kind":"docker",
|
||||
"name":"docker-host",
|
||||
"host_ip":"10.1.1.1",
|
||||
"new_group":{"name":"Docker Hosts"},
|
||||
"generate":{"algorithm":"ed25519","kdf_rounds":64}
|
||||
}`)
|
||||
request := withSession(
|
||||
httptest.NewRequest(http.MethodPost, "/api/v1/nodes", bytes.NewReader(body)),
|
||||
cookie,
|
||||
)
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
|
||||
var response nodeResponse
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if response.Node.GroupName != "Docker Hosts" {
|
||||
t.Fatalf("group = %q", response.Node.GroupName)
|
||||
}
|
||||
|
||||
settingsPayload, err := settings.LoadSettings(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSettings: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, group := range settingsPayload.Groups {
|
||||
if group.Name == "Docker Hosts" {
|
||||
found = true
|
||||
if group.ScopeKind != settings.GroupScopeNode {
|
||||
t.Fatalf("scope kind = %q", group.ScopeKind)
|
||||
}
|
||||
if group.ScopeName != response.Node.ID {
|
||||
t.Fatalf("scope name = %q want %q", group.ScopeName, response.Node.ID)
|
||||
}
|
||||
if len(group.Permissions) != 0 {
|
||||
t.Fatalf("expected empty permissions, got %v", group.Permissions)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("new group not saved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodesTestSSHReportsFailureForUnreachableHost(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
router := NewRouter(configDir)
|
||||
|
||||
createBody := []byte(`{
|
||||
"id":"aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
|
||||
"kind":"container",
|
||||
"name":"unreachable",
|
||||
"host_ip":"127.0.0.1",
|
||||
"group_name":"Administrators",
|
||||
"generate":{"algorithm":"ed25519"}
|
||||
}`)
|
||||
createRequest := withSession(
|
||||
httptest.NewRequest(http.MethodPost, "/api/v1/nodes", bytes.NewReader(createBody)),
|
||||
cookie,
|
||||
)
|
||||
createRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(createRecorder, createRequest)
|
||||
if createRecorder.Code != http.StatusCreated {
|
||||
t.Fatalf("create status = %d body=%s", createRecorder.Code, createRecorder.Body.String())
|
||||
}
|
||||
|
||||
// 127.0.0.1 with no SSH listener (or auth failure) should not panic; expect ok=false.
|
||||
testRequest := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/nodes/aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee/test-ssh",
|
||||
nil,
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
testRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(testRecorder, testRequest)
|
||||
if testRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("test status = %d body=%s", testRecorder.Code, testRecorder.Body.String())
|
||||
}
|
||||
|
||||
var response nodeSSHTestResponse
|
||||
if err := json.Unmarshal(testRecorder.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if response.OK {
|
||||
t.Fatal("expected SSH test to fail against localhost without matching key/auth")
|
||||
}
|
||||
if response.Message == "" {
|
||||
t.Fatal("expected failure message")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||
)
|
||||
|
||||
const reauthRequiredMessage = "reauth_required"
|
||||
|
||||
type reauthRequest struct {
|
||||
Password string `json:"password"`
|
||||
TOTPCode string `json:"totp_code"`
|
||||
}
|
||||
|
||||
func SessionFromContext(ctx context.Context) (settings.SessionRecord, bool) {
|
||||
session, ok := ctx.Value(contextSessionKey).(settings.SessionRecord)
|
||||
return session, ok
|
||||
}
|
||||
|
||||
func verifyActorCredentials(user settings.UserCredential, password string, totpCode string, security settings.SecuritySettings) error {
|
||||
ok, err := auth.VerifyPassword(password, user.PasswordHash)
|
||||
if err != nil || !ok {
|
||||
return errors.New("invalid credentials")
|
||||
}
|
||||
|
||||
requiresTOTP := security.TotpEnabled && user.TOTPConfirmed && user.TOTPSecret != ""
|
||||
if requiresTOTP {
|
||||
if !auth.VerifyTOTPCode(user.TOTPSecret, totpCode) {
|
||||
return errors.New("invalid or missing TOTP code")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *App) requireInlineReauth(
|
||||
request *http.Request,
|
||||
password string,
|
||||
totpCode string,
|
||||
) error {
|
||||
user, ok := UserFromContext(request.Context())
|
||||
if !ok {
|
||||
return errors.New("authentication required")
|
||||
}
|
||||
|
||||
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
security := effectiveSecurity(settingsPayload.Security)
|
||||
return verifyActorCredentials(user, password, totpCode, security)
|
||||
}
|
||||
|
||||
// requireGraceReauth returns an error with message reauth_required when the
|
||||
// session is outside the grace window. When reauth_sensitive_actions is off, it
|
||||
// is a no-op.
|
||||
func (app *App) requireGraceReauth(request *http.Request) error {
|
||||
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
security := effectiveSecurity(settingsPayload.Security)
|
||||
if !security.ReauthSensitiveActions {
|
||||
return nil
|
||||
}
|
||||
|
||||
session, ok := SessionFromContext(request.Context())
|
||||
if !ok {
|
||||
return errors.New("authentication required")
|
||||
}
|
||||
|
||||
grace := time.Duration(security.ReauthGraceMinutes) * time.Minute
|
||||
elapsed := time.Since(session.LastReauthAt)
|
||||
// Allow a short post-reauth window so grace=0 still supports reauth-then-retry.
|
||||
const justReauthedWindow = 30 * time.Second
|
||||
if elapsed <= grace || elapsed <= justReauthedWindow {
|
||||
return nil
|
||||
}
|
||||
return errors.New(reauthRequiredMessage)
|
||||
}
|
||||
|
||||
func writeReauthRequired(writer http.ResponseWriter) {
|
||||
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: reauthRequiredMessage})
|
||||
}
|
||||
@@ -27,6 +27,12 @@ func NewRouter(configDir string) http.Handler {
|
||||
mux.HandleFunc("POST /api/v1/auth/login", app.loginHandler)
|
||||
mux.HandleFunc("POST /api/v1/auth/logout", app.logoutHandler)
|
||||
mux.HandleFunc("GET /api/v1/auth/me", app.meHandler)
|
||||
mux.HandleFunc("POST /api/v1/auth/reauth", app.reauthHandler)
|
||||
|
||||
mux.HandleFunc("PUT /api/v1/me/password", app.mePasswordHandler)
|
||||
mux.HandleFunc("POST /api/v1/me/totp/begin", app.meTOTPBeginHandler)
|
||||
mux.HandleFunc("POST /api/v1/me/totp/confirm", app.meTOTPConfirmHandler)
|
||||
mux.HandleFunc("POST /api/v1/me/totp/disable", app.meTOTPDisableHandler)
|
||||
|
||||
mux.HandleFunc("GET /api/v1/groups", groupsGetHandler(configDir))
|
||||
mux.HandleFunc("POST /api/v1/groups", groupsUpsertHandler(configDir))
|
||||
@@ -36,7 +42,14 @@ func NewRouter(configDir string) http.Handler {
|
||||
mux.HandleFunc("GET /api/v1/network", networkGetHandler(configDir))
|
||||
mux.HandleFunc("PUT /api/v1/network", networkPutHandler(configDir))
|
||||
mux.HandleFunc("GET /api/v1/users", app.usersGetHandler)
|
||||
mux.HandleFunc("POST /api/v1/users", app.usersCreateHandler)
|
||||
mux.HandleFunc("PATCH /api/v1/users", app.usersPatchHandler)
|
||||
mux.HandleFunc("DELETE /api/v1/users", app.usersDeleteHandler)
|
||||
|
||||
mux.HandleFunc("GET /api/v1/nodes", app.nodesListHandler)
|
||||
mux.HandleFunc("POST /api/v1/nodes", app.nodesCreateHandler)
|
||||
mux.HandleFunc("GET /api/v1/nodes/{id}", app.nodesGetHandler)
|
||||
mux.HandleFunc("POST /api/v1/nodes/{id}/test-ssh", app.nodesTestSSHHandler)
|
||||
|
||||
return app.withCORS(app.withMiddleware(mux))
|
||||
}
|
||||
|
||||
@@ -66,9 +66,12 @@ type loginRequest struct {
|
||||
}
|
||||
|
||||
type meResponse struct {
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Groups []string `json:"groups"`
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Groups []string `json:"groups"`
|
||||
TOTPConfirmed bool `json:"totp_confirmed"`
|
||||
TOTPEnabled bool `json:"totp_enabled"`
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
|
||||
// App holds shared API dependencies.
|
||||
@@ -388,11 +391,7 @@ func (app *App) loginHandler(writer http.ResponseWriter, request *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, meResponse{
|
||||
UserID: matched.ID,
|
||||
Username: matched.Username,
|
||||
Groups: matched.GroupNames,
|
||||
})
|
||||
writeJSON(writer, http.StatusOK, app.buildMeResponse(*matched, settingsPayload))
|
||||
}
|
||||
|
||||
func (app *App) logoutHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
@@ -408,11 +407,32 @@ func (app *App) meHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "not authenticated"})
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, meResponse{
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Groups: user.GroupNames,
|
||||
})
|
||||
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, app.buildMeResponse(user, settingsPayload))
|
||||
}
|
||||
|
||||
func (app *App) buildMeResponse(user settings.UserCredential, settingsPayload settings.Settings) meResponse {
|
||||
security := effectiveSecurity(settingsPayload.Security)
|
||||
groups := user.GroupNames
|
||||
if groups == nil {
|
||||
groups = []string{}
|
||||
}
|
||||
permissions := permissionList(user, settingsPayload.Groups)
|
||||
if permissions == nil {
|
||||
permissions = []string{}
|
||||
}
|
||||
return meResponse{
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Groups: groups,
|
||||
TOTPConfirmed: user.TOTPConfirmed,
|
||||
TOTPEnabled: security.TotpEnabled,
|
||||
Permissions: permissions,
|
||||
}
|
||||
}
|
||||
|
||||
func UserFromContext(ctx context.Context) (settings.UserCredential, bool) {
|
||||
|
||||
@@ -42,9 +42,16 @@ func seedCompletedSetup(t *testing.T, configDir string) *http.Cookie {
|
||||
security := settings.DefaultSecuritySettings()
|
||||
payload := settings.Settings{
|
||||
SetupCompleted: true,
|
||||
Groups: []settings.Group{},
|
||||
Security: security,
|
||||
Network: settings.DefaultNetworkSettings(),
|
||||
Groups: []settings.Group{
|
||||
{
|
||||
Name: settings.AdministratorsGroupName,
|
||||
ScopeKind: settings.GroupScopeGroup,
|
||||
ScopeName: "Admin Group",
|
||||
Permissions: allPermissionList(),
|
||||
},
|
||||
},
|
||||
Security: security,
|
||||
Network: settings.DefaultNetworkSettings(),
|
||||
}
|
||||
if err := settings.SaveSettings(configDir, payload); err != nil {
|
||||
t.Fatalf("SaveSettings: %v", err)
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||
)
|
||||
|
||||
@@ -24,6 +27,23 @@ type usersResponse struct {
|
||||
Users []userPublic `json:"users"`
|
||||
}
|
||||
|
||||
type createUserRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
GroupNames []string `json:"group_names"`
|
||||
}
|
||||
|
||||
type patchUserRequest struct {
|
||||
GroupNames *[]string `json:"group_names"`
|
||||
Password *string `json:"password"`
|
||||
DisableTOTP *bool `json:"disable_totp"`
|
||||
}
|
||||
|
||||
type deleteUserRequest struct {
|
||||
CurrentPassword string `json:"current_password"`
|
||||
TOTPCode string `json:"totp_code"`
|
||||
}
|
||||
|
||||
func (app *App) usersGetHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := app.requireKey(); err != nil {
|
||||
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()})
|
||||
@@ -39,8 +59,206 @@ func (app *App) usersGetHandler(writer http.ResponseWriter, request *http.Reques
|
||||
writeJSON(writer, http.StatusOK, usersResponse{Users: toPublicUsers(store.Users)})
|
||||
}
|
||||
|
||||
func (app *App) usersCreateHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := app.authorizeUsersWrite(request); err != nil {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if err := app.requireKey(); err != nil {
|
||||
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var payload createUserRequest
|
||||
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
|
||||
username := strings.TrimSpace(payload.Username)
|
||||
if username == "" {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "username is required"})
|
||||
return
|
||||
}
|
||||
if err := auth.ValidatePassword(payload.Password, username); err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
groupNames, err := validateGroupNames(payload.GroupNames, settingsPayload.Groups)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
store, err := settings.LoadPasswords(app.ConfigDir, app.Key)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
for _, existing := range store.Users {
|
||||
if strings.EqualFold(existing.Username, username) {
|
||||
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "username already exists"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(payload.Password)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
userID, err := newUserID()
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
store.Users = append(store.Users, settings.UserCredential{
|
||||
ID: userID,
|
||||
Username: username,
|
||||
PasswordHash: hash,
|
||||
Enabled: true,
|
||||
GroupNames: groupNames,
|
||||
CreatedAt: now,
|
||||
PasswordChangedAt: now,
|
||||
})
|
||||
|
||||
if err := settings.SavePasswords(app.ConfigDir, store, app.Key); err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, usersResponse{Users: toPublicUsers(store.Users)})
|
||||
}
|
||||
|
||||
func (app *App) usersPatchHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := app.authorizeUsersWrite(request); err != nil {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if err := app.requireKey(); err != nil {
|
||||
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := app.requireGraceReauth(request); err != nil {
|
||||
if err.Error() == reauthRequiredMessage {
|
||||
writeReauthRequired(writer)
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
userID := strings.TrimSpace(request.URL.Query().Get("id"))
|
||||
if userID == "" {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "missing user id"})
|
||||
return
|
||||
}
|
||||
|
||||
var payload patchUserRequest
|
||||
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
if payload.GroupNames == nil && payload.Password == nil && payload.DisableTOTP == nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "no fields to update"})
|
||||
return
|
||||
}
|
||||
|
||||
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
store, err := settings.LoadPasswords(app.ConfigDir, app.Key)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
index := -1
|
||||
for i := range store.Users {
|
||||
if store.Users[i].ID == userID {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if index < 0 {
|
||||
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "user not found"})
|
||||
return
|
||||
}
|
||||
|
||||
target := &store.Users[index]
|
||||
passwordChanged := false
|
||||
|
||||
if payload.GroupNames != nil {
|
||||
groupNames, err := validateGroupNames(*payload.GroupNames, settingsPayload.Groups)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
wasAdmin := userIsAdministrator(*target)
|
||||
wouldBeAdmin := false
|
||||
for _, name := range groupNames {
|
||||
if name == settings.AdministratorsGroupName {
|
||||
wouldBeAdmin = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if wasAdmin && !wouldBeAdmin && countAdministrators(store.Users) <= 1 {
|
||||
writeJSON(writer, http.StatusConflict, apiErrorResponse{
|
||||
Error: "cannot remove the last administrator from the Administrators group",
|
||||
})
|
||||
return
|
||||
}
|
||||
target.GroupNames = groupNames
|
||||
}
|
||||
|
||||
if payload.Password != nil {
|
||||
if err := auth.ValidatePassword(*payload.Password, target.Username); err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
hash, err := auth.HashPassword(*payload.Password)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
target.PasswordHash = hash
|
||||
target.PasswordChangedAt = time.Now().UTC()
|
||||
passwordChanged = true
|
||||
}
|
||||
|
||||
if payload.DisableTOTP != nil && *payload.DisableTOTP {
|
||||
target.TOTPSecret = ""
|
||||
target.TOTPConfirmed = false
|
||||
}
|
||||
|
||||
if err := settings.SavePasswords(app.ConfigDir, store, app.Key); err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if passwordChanged && app.Sessions != nil {
|
||||
_ = app.Sessions.InvalidateUserSessions(userID)
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, usersResponse{Users: toPublicUsers(store.Users)})
|
||||
}
|
||||
|
||||
func (app *App) usersDeleteHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := authorizeUsersWrite(request); err != nil {
|
||||
if err := app.authorizeUsersWrite(request); err != nil {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -55,6 +273,17 @@ func (app *App) usersDeleteHandler(writer http.ResponseWriter, request *http.Req
|
||||
return
|
||||
}
|
||||
|
||||
var payload deleteUserRequest
|
||||
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := app.requireInlineReauth(request, payload.CurrentPassword, payload.TOTPCode); err != nil {
|
||||
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
store, err := settings.LoadPasswords(app.ConfigDir, app.Key)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
@@ -96,9 +325,31 @@ func (app *App) usersDeleteHandler(writer http.ResponseWriter, request *http.Req
|
||||
writeJSON(writer, http.StatusOK, usersResponse{Users: toPublicUsers(store.Users)})
|
||||
}
|
||||
|
||||
func authorizeUsersWrite(_ *http.Request) error {
|
||||
// TODO: integrate authz middleware.
|
||||
return nil
|
||||
func validateGroupNames(names []string, groups []settings.Group) ([]string, error) {
|
||||
if names == nil {
|
||||
return []string{}, nil
|
||||
}
|
||||
known := make(map[string]struct{}, len(groups))
|
||||
for _, group := range groups {
|
||||
known[group.Name] = struct{}{}
|
||||
}
|
||||
result := make([]string, 0, len(names))
|
||||
seen := make(map[string]struct{}, len(names))
|
||||
for _, name := range names {
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := known[trimmed]; !ok {
|
||||
return nil, fmt.Errorf("unknown group: %s", trimmed)
|
||||
}
|
||||
if _, ok := seen[trimmed]; ok {
|
||||
continue
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func toPublicUsers(users []settings.UserCredential) []userPublic {
|
||||
|
||||
@@ -64,9 +64,14 @@ func TestUsersDeleteRejectsLastAdministrator(t *testing.T) {
|
||||
router := NewRouter(configDir)
|
||||
|
||||
request := withSession(
|
||||
httptest.NewRequest(http.MethodDelete, "/api/v1/users?id=test-user-id", nil),
|
||||
httptest.NewRequest(
|
||||
http.MethodDelete,
|
||||
"/api/v1/users?id=test-user-id",
|
||||
strings.NewReader(`{"current_password":"correct horse battery staple extra"}`),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
@@ -126,11 +131,13 @@ func TestUsersDeleteAllowsNonAdminAndSecondAdmin(t *testing.T) {
|
||||
}
|
||||
|
||||
router := NewRouter(configDir)
|
||||
reauthBody := `{"current_password":"correct horse battery staple extra"}`
|
||||
|
||||
deleteOperator := withSession(
|
||||
httptest.NewRequest(http.MethodDelete, "/api/v1/users?id=operator-id", nil),
|
||||
httptest.NewRequest(http.MethodDelete, "/api/v1/users?id=operator-id", strings.NewReader(reauthBody)),
|
||||
cookie,
|
||||
)
|
||||
deleteOperator.Header.Set("Content-Type", "application/json")
|
||||
operatorRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(operatorRec, deleteOperator)
|
||||
if operatorRec.Code != http.StatusOK {
|
||||
@@ -138,9 +145,10 @@ func TestUsersDeleteAllowsNonAdminAndSecondAdmin(t *testing.T) {
|
||||
}
|
||||
|
||||
deleteSecondAdmin := withSession(
|
||||
httptest.NewRequest(http.MethodDelete, "/api/v1/users?id=admin-two-id", nil),
|
||||
httptest.NewRequest(http.MethodDelete, "/api/v1/users?id=admin-two-id", strings.NewReader(reauthBody)),
|
||||
cookie,
|
||||
)
|
||||
deleteSecondAdmin.Header.Set("Content-Type", "application/json")
|
||||
adminRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(adminRec, deleteSecondAdmin)
|
||||
if adminRec.Code != http.StatusOK {
|
||||
@@ -182,13 +190,275 @@ func TestUsersDeleteUnknownUserNotFound(t *testing.T) {
|
||||
router := NewRouter(configDir)
|
||||
|
||||
request := withSession(
|
||||
httptest.NewRequest(http.MethodDelete, "/api/v1/users?id=missing", nil),
|
||||
httptest.NewRequest(
|
||||
http.MethodDelete,
|
||||
"/api/v1/users?id=missing",
|
||||
strings.NewReader(`{"current_password":"correct horse battery staple extra"}`),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusNotFound, recorder.Code)
|
||||
t.Fatalf("expected status %d, got %d body=%s", http.StatusNotFound, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsersCreateAndPatch(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
|
||||
key, err := settings.KeyFromEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("KeyFromEnv: %v", err)
|
||||
}
|
||||
payload, err := settings.LoadSettings(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSettings: %v", err)
|
||||
}
|
||||
payload.Groups = append(payload.Groups, settings.Group{
|
||||
Name: "Operators",
|
||||
ScopeKind: settings.GroupScopeGroup,
|
||||
ScopeName: "Ops",
|
||||
Permissions: []string{"nodes.read"},
|
||||
})
|
||||
if err := settings.SaveSettings(configDir, payload); err != nil {
|
||||
t.Fatalf("SaveSettings: %v", err)
|
||||
}
|
||||
|
||||
router := NewRouter(configDir)
|
||||
|
||||
createReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/users",
|
||||
strings.NewReader(`{"username":"alice","password":"correct horse battery staple extra","group_names":["Operators"]}`),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
createReq.Header.Set("Content-Type", "application/json")
|
||||
createRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(createRec, createReq)
|
||||
if createRec.Code != http.StatusOK {
|
||||
t.Fatalf("create: expected %d, got %d body=%s", http.StatusOK, createRec.Code, createRec.Body.String())
|
||||
}
|
||||
|
||||
var created usersResponseTest
|
||||
if err := json.NewDecoder(createRec.Body).Decode(&created); err != nil {
|
||||
t.Fatalf("decode create: %v", err)
|
||||
}
|
||||
var aliceID string
|
||||
for _, user := range created.Users {
|
||||
if user.Username == "alice" {
|
||||
aliceID = user.ID
|
||||
if len(user.GroupNames) != 1 || user.GroupNames[0] != "Operators" {
|
||||
t.Fatalf("unexpected groups: %#v", user.GroupNames)
|
||||
}
|
||||
}
|
||||
}
|
||||
if aliceID == "" {
|
||||
t.Fatal("alice not found after create")
|
||||
}
|
||||
|
||||
patchReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPatch,
|
||||
"/api/v1/users?id="+aliceID,
|
||||
strings.NewReader(`{"disable_totp":true,"password":"another strong passphrase here"}`),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
patchReq.Header.Set("Content-Type", "application/json")
|
||||
patchRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(patchRec, patchReq)
|
||||
if patchRec.Code != http.StatusOK {
|
||||
t.Fatalf("patch: expected %d, got %d body=%s", http.StatusOK, patchRec.Code, patchRec.Body.String())
|
||||
}
|
||||
|
||||
store, err := settings.LoadPasswords(configDir, key)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadPasswords: %v", err)
|
||||
}
|
||||
for _, user := range store.Users {
|
||||
if user.ID != aliceID {
|
||||
continue
|
||||
}
|
||||
if user.TOTPConfirmed || user.TOTPSecret != "" {
|
||||
t.Fatal("expected TOTP cleared")
|
||||
}
|
||||
ok, err := auth.VerifyPassword("another strong passphrase here", user.PasswordHash)
|
||||
if err != nil || !ok {
|
||||
t.Fatal("expected password updated")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsersWriteRequiresPermission(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
|
||||
key, err := settings.KeyFromEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("KeyFromEnv: %v", err)
|
||||
}
|
||||
payload, err := settings.LoadSettings(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSettings: %v", err)
|
||||
}
|
||||
// Strip users.manage from Administrators
|
||||
for index := range payload.Groups {
|
||||
if payload.Groups[index].Name != settings.AdministratorsGroupName {
|
||||
continue
|
||||
}
|
||||
filtered := make([]string, 0)
|
||||
for _, permission := range payload.Groups[index].Permissions {
|
||||
if permission == "users.manage" {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, permission)
|
||||
}
|
||||
payload.Groups[index].Permissions = filtered
|
||||
}
|
||||
if err := settings.SaveSettings(configDir, payload); err != nil {
|
||||
t.Fatalf("SaveSettings: %v", err)
|
||||
}
|
||||
_ = key
|
||||
|
||||
router := NewRouter(configDir)
|
||||
request := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/users",
|
||||
strings.NewReader(`{"username":"bob","password":"correct horse battery staple extra"}`),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected %d, got %d body=%s", http.StatusForbidden, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsersPatchRequiresGraceReauth(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
|
||||
key, err := settings.KeyFromEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("KeyFromEnv: %v", err)
|
||||
}
|
||||
payload, err := settings.LoadSettings(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSettings: %v", err)
|
||||
}
|
||||
payload.Security.ReauthSensitiveActions = true
|
||||
payload.Security.ReauthGraceMinutes = 0
|
||||
if err := settings.SaveSettings(configDir, payload); err != nil {
|
||||
t.Fatalf("SaveSettings: %v", err)
|
||||
}
|
||||
|
||||
// Age the session's LastReauthAt so grace (0) always fails unless MarkReauth just ran.
|
||||
store, err := settings.LoadSessionsOrEmpty(configDir, key)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSessions: %v", err)
|
||||
}
|
||||
for index := range store.Sessions {
|
||||
store.Sessions[index].LastReauthAt = time.Now().UTC().Add(-time.Minute)
|
||||
}
|
||||
if err := settings.SaveSessions(configDir, store, key); err != nil {
|
||||
t.Fatalf("SaveSessions: %v", err)
|
||||
}
|
||||
|
||||
router := NewRouter(configDir)
|
||||
patchReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPatch,
|
||||
"/api/v1/users?id=test-user-id",
|
||||
strings.NewReader(`{"password":"another strong passphrase here"}`),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
patchReq.Header.Set("Content-Type", "application/json")
|
||||
patchRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(patchRec, patchReq)
|
||||
if patchRec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected reauth_required status %d, got %d body=%s", http.StatusUnauthorized, patchRec.Code, patchRec.Body.String())
|
||||
}
|
||||
var errPayload apiErrorResponseTest
|
||||
if err := json.NewDecoder(patchRec.Body).Decode(&errPayload); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if errPayload.Error != "reauth_required" {
|
||||
t.Fatalf("expected reauth_required, got %q", errPayload.Error)
|
||||
}
|
||||
|
||||
reauthReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/auth/reauth",
|
||||
strings.NewReader(`{"password":"correct horse battery staple extra"}`),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
reauthReq.Header.Set("Content-Type", "application/json")
|
||||
reauthRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(reauthRec, reauthReq)
|
||||
if reauthRec.Code != http.StatusOK {
|
||||
t.Fatalf("reauth: expected %d, got %d body=%s", http.StatusOK, reauthRec.Code, reauthRec.Body.String())
|
||||
}
|
||||
|
||||
// Refresh cookie if rotated
|
||||
for _, setCookie := range reauthRec.Result().Cookies() {
|
||||
if setCookie.Name == auth.SessionCookieName {
|
||||
cookie = setCookie
|
||||
}
|
||||
}
|
||||
|
||||
retryReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPatch,
|
||||
"/api/v1/users?id=test-user-id",
|
||||
strings.NewReader(`{"password":"another strong passphrase here"}`),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
retryReq.Header.Set("Content-Type", "application/json")
|
||||
retryRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(retryRec, retryReq)
|
||||
if retryRec.Code != http.StatusOK {
|
||||
t.Fatalf("retry patch: expected %d, got %d body=%s", http.StatusOK, retryRec.Code, retryRec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMePasswordChange(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
router := NewRouter(configDir)
|
||||
|
||||
request := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/api/v1/me/password",
|
||||
strings.NewReader(`{"current_password":"correct horse battery staple extra","new_password":"fresh strong passphrase words"}`),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
|
||||
// Session should be invalidated
|
||||
meReq := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil), cookie)
|
||||
meRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(meRec, meReq)
|
||||
if meRec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected session invalidated, got %d", meRec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,11 +66,12 @@ func (manager *SessionManager) CreateSession(
|
||||
}
|
||||
|
||||
record := settings.SessionRecord{
|
||||
ID: sessionID,
|
||||
UserID: userID,
|
||||
CreatedAt: now,
|
||||
LastSeenAt: now,
|
||||
ExpiresAt: now.Add(lifetime),
|
||||
ID: sessionID,
|
||||
UserID: userID,
|
||||
CreatedAt: now,
|
||||
LastSeenAt: now,
|
||||
LastReauthAt: now,
|
||||
ExpiresAt: now.Add(lifetime),
|
||||
}
|
||||
filtered = append(filtered, record)
|
||||
store.Sessions = filtered
|
||||
@@ -142,6 +143,7 @@ func (manager *SessionManager) LookupValidSession(
|
||||
}
|
||||
found.ID = newID
|
||||
found.CreatedAt = now
|
||||
// LastReauthAt is preserved across rotation.
|
||||
setSessionCookie(writer, newID, int(found.ExpiresAt.Sub(now).Seconds()))
|
||||
}
|
||||
|
||||
@@ -153,6 +155,32 @@ func (manager *SessionManager) LookupValidSession(
|
||||
return *found, nil
|
||||
}
|
||||
|
||||
// MarkReauth updates LastReauthAt for the session identified by sessionID.
|
||||
func (manager *SessionManager) MarkReauth(sessionID string) error {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
|
||||
store, err := settings.LoadSessionsOrEmpty(manager.configDir, manager.key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
found := false
|
||||
for index := range store.Sessions {
|
||||
if store.Sessions[index].ID != sessionID {
|
||||
continue
|
||||
}
|
||||
store.Sessions[index].LastReauthAt = now
|
||||
found = true
|
||||
break
|
||||
}
|
||||
if !found {
|
||||
return ErrSessionNotFound
|
||||
}
|
||||
return settings.SaveSessions(manager.configDir, store, manager.key)
|
||||
}
|
||||
|
||||
// InvalidateUserSessions removes all persisted sessions for the given user ID.
|
||||
func (manager *SessionManager) InvalidateUserSessions(userID string) error {
|
||||
manager.mu.Lock()
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
const defaultSSHPort = "22"
|
||||
const sshDialTimeout = 10 * time.Second
|
||||
|
||||
// TestSSHConnection dials host over SSH using the given private key and username.
|
||||
// Host key verification is intentionally skipped for this connectivity check;
|
||||
// trust-on-first-use / known_hosts can be added later.
|
||||
func TestSSHConnection(hostIP string, username string, privateKeyPEM string, passphrase string) error {
|
||||
hostIP = strings.TrimSpace(hostIP)
|
||||
username = strings.TrimSpace(username)
|
||||
if hostIP == "" {
|
||||
return fmt.Errorf("host IP is required")
|
||||
}
|
||||
if username == "" {
|
||||
return fmt.Errorf("username is required")
|
||||
}
|
||||
|
||||
signer, err := parseSigner(privateKeyPEM, passphrase)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
config := &ssh.ClientConfig{
|
||||
User: username,
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.PublicKeys(signer),
|
||||
},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
Timeout: sshDialTimeout,
|
||||
}
|
||||
|
||||
address := net.JoinHostPort(hostIP, defaultSSHPort)
|
||||
client, err := ssh.Dial("tcp", address, config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ssh dial %s: %w", address, err)
|
||||
}
|
||||
defer client.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseSigner(privateKeyPEM string, passphrase string) (ssh.Signer, error) {
|
||||
trimmed := strings.TrimSpace(privateKeyPEM)
|
||||
if trimmed == "" {
|
||||
return nil, fmt.Errorf("private key is required")
|
||||
}
|
||||
|
||||
var (
|
||||
signer ssh.Signer
|
||||
err error
|
||||
)
|
||||
if strings.TrimSpace(passphrase) == "" {
|
||||
signer, err = ssh.ParsePrivateKey([]byte(trimmed))
|
||||
} else {
|
||||
signer, err = ssh.ParsePrivateKeyWithPassphrase(
|
||||
[]byte(trimmed),
|
||||
[]byte(passphrase),
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse private key: %w", err)
|
||||
}
|
||||
return signer, nil
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTestSSHConnectionRejectsEmptyHost(t *testing.T) {
|
||||
err := TestSSHConnection("", "clustercanvas", "not-a-key", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestSSHConnectionRejectsInvalidKey(t *testing.T) {
|
||||
err := TestSSHConnection("127.0.0.1", "clustercanvas", "not-a-key", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
const (
|
||||
SSHKeyAlgoEd25519 = "ed25519"
|
||||
SSHKeyAlgoRSA = "rsa"
|
||||
|
||||
DefaultSSHUsername = "clustercanvas"
|
||||
DefaultRSABits = 4096
|
||||
DefaultEd25519Rounds = 100
|
||||
)
|
||||
|
||||
// GeneratedSSHKey is the result of generating or importing an SSH key pair.
|
||||
type GeneratedSSHKey struct {
|
||||
PrivateKeyPEM string
|
||||
PublicKey string
|
||||
Algorithm string
|
||||
RSABits int
|
||||
KDFRounds int
|
||||
}
|
||||
|
||||
// GenerateSSHKey creates an OpenSSH private key and authorized_keys public line.
|
||||
//
|
||||
// Private keys are written without an OpenSSH passphrase. At-rest protection is
|
||||
// provided by ClusterCanvas AES-GCM (node-keys.enc). KDFRounds is retained as
|
||||
// generation metadata (ssh-keygen -a) for future passphrase-protected exports;
|
||||
// golang.org/x/crypto/ssh hardcodes bcrypt rounds when encrypting.
|
||||
func GenerateSSHKey(algorithm string, rsaBits int, kdfRounds int) (GeneratedSSHKey, error) {
|
||||
algorithm = strings.ToLower(strings.TrimSpace(algorithm))
|
||||
switch algorithm {
|
||||
case SSHKeyAlgoEd25519:
|
||||
if kdfRounds <= 0 {
|
||||
kdfRounds = DefaultEd25519Rounds
|
||||
}
|
||||
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return GeneratedSSHKey{}, fmt.Errorf("generate ed25519 key: %w", err)
|
||||
}
|
||||
return marshalGeneratedKey(privateKey, SSHKeyAlgoEd25519, 0, kdfRounds)
|
||||
case SSHKeyAlgoRSA:
|
||||
if rsaBits == 0 {
|
||||
rsaBits = DefaultRSABits
|
||||
}
|
||||
if !ValidRSABits(rsaBits) {
|
||||
return GeneratedSSHKey{}, fmt.Errorf("rsa bits must be one of 2048, 3072, or 4096")
|
||||
}
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, rsaBits)
|
||||
if err != nil {
|
||||
return GeneratedSSHKey{}, fmt.Errorf("generate rsa key: %w", err)
|
||||
}
|
||||
return marshalGeneratedKey(privateKey, SSHKeyAlgoRSA, rsaBits, 0)
|
||||
default:
|
||||
return GeneratedSSHKey{}, fmt.Errorf("algorithm must be %q or %q", SSHKeyAlgoEd25519, SSHKeyAlgoRSA)
|
||||
}
|
||||
}
|
||||
|
||||
// ParseSSHPrivateKey accepts an OpenSSH/PEM private key and derives the public key.
|
||||
func ParseSSHPrivateKey(privateKeyPEM string) (GeneratedSSHKey, error) {
|
||||
trimmed := strings.TrimSpace(privateKeyPEM)
|
||||
if trimmed == "" {
|
||||
return GeneratedSSHKey{}, errors.New("private_key is required")
|
||||
}
|
||||
|
||||
rawKey, err := ssh.ParseRawPrivateKey([]byte(trimmed))
|
||||
if err != nil {
|
||||
return GeneratedSSHKey{}, fmt.Errorf("invalid private key: %w", err)
|
||||
}
|
||||
|
||||
signer, ok := rawKey.(crypto.Signer)
|
||||
if !ok {
|
||||
return GeneratedSSHKey{}, errors.New("private key does not support signing")
|
||||
}
|
||||
|
||||
sshPublicKey, err := ssh.NewPublicKey(signer.Public())
|
||||
if err != nil {
|
||||
return GeneratedSSHKey{}, fmt.Errorf("derive public key: %w", err)
|
||||
}
|
||||
|
||||
algorithm := SSHKeyAlgoEd25519
|
||||
rsaBits := 0
|
||||
switch key := rawKey.(type) {
|
||||
case *ed25519.PrivateKey, ed25519.PrivateKey:
|
||||
algorithm = SSHKeyAlgoEd25519
|
||||
case *rsa.PrivateKey:
|
||||
algorithm = SSHKeyAlgoRSA
|
||||
rsaBits = key.N.BitLen()
|
||||
default:
|
||||
// Keep OpenSSH type string for unusual keys.
|
||||
algorithm = sshPublicKey.Type()
|
||||
}
|
||||
|
||||
return GeneratedSSHKey{
|
||||
PrivateKeyPEM: trimmed + "\n",
|
||||
PublicKey: strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPublicKey))),
|
||||
Algorithm: algorithm,
|
||||
RSABits: rsaBits,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidRSABits reports whether bits is an allowed RSA size.
|
||||
func ValidRSABits(bits int) bool {
|
||||
switch bits {
|
||||
case 2048, 3072, 4096:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func marshalGeneratedKey(privateKey crypto.PrivateKey, algorithm string, rsaBits int, kdfRounds int) (GeneratedSSHKey, error) {
|
||||
block, err := ssh.MarshalPrivateKey(privateKey, "")
|
||||
if err != nil {
|
||||
return GeneratedSSHKey{}, fmt.Errorf("marshal private key: %w", err)
|
||||
}
|
||||
|
||||
signer, ok := privateKey.(crypto.Signer)
|
||||
if !ok {
|
||||
return GeneratedSSHKey{}, errors.New("private key does not support signing")
|
||||
}
|
||||
sshPublicKey, err := ssh.NewPublicKey(signer.Public())
|
||||
if err != nil {
|
||||
return GeneratedSSHKey{}, fmt.Errorf("derive public key: %w", err)
|
||||
}
|
||||
|
||||
privatePEM := string(pem.EncodeToMemory(block))
|
||||
return GeneratedSSHKey{
|
||||
PrivateKeyPEM: privatePEM,
|
||||
PublicKey: strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPublicKey))),
|
||||
Algorithm: algorithm,
|
||||
RSABits: rsaBits,
|
||||
KDFRounds: kdfRounds,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateSSHKeyEd25519(t *testing.T) {
|
||||
generated, err := GenerateSSHKey(SSHKeyAlgoEd25519, 0, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSSHKey: %v", err)
|
||||
}
|
||||
if generated.Algorithm != SSHKeyAlgoEd25519 {
|
||||
t.Fatalf("algorithm = %q", generated.Algorithm)
|
||||
}
|
||||
if generated.KDFRounds != 100 {
|
||||
t.Fatalf("kdf rounds = %d", generated.KDFRounds)
|
||||
}
|
||||
if !strings.Contains(generated.PrivateKeyPEM, "BEGIN OPENSSH PRIVATE KEY") {
|
||||
t.Fatal("expected openssh private key")
|
||||
}
|
||||
if !strings.HasPrefix(generated.PublicKey, "ssh-ed25519 ") {
|
||||
t.Fatalf("public key = %q", generated.PublicKey)
|
||||
}
|
||||
|
||||
parsed, err := ParseSSHPrivateKey(generated.PrivateKeyPEM)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseSSHPrivateKey: %v", err)
|
||||
}
|
||||
if parsed.PublicKey != generated.PublicKey {
|
||||
t.Fatalf("parsed public key mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSSHKeyRSABits(t *testing.T) {
|
||||
generated, err := GenerateSSHKey(SSHKeyAlgoRSA, 2048, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSSHKey: %v", err)
|
||||
}
|
||||
if generated.RSABits != 2048 {
|
||||
t.Fatalf("rsa bits = %d", generated.RSABits)
|
||||
}
|
||||
if !strings.HasPrefix(generated.PublicKey, "ssh-rsa ") {
|
||||
t.Fatalf("public key = %q", generated.PublicKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSSHKeyRejectsInvalidRSABits(t *testing.T) {
|
||||
_, err := GenerateSSHKey(SSHKeyAlgoRSA, 1024, 0)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewUUIDFormat(t *testing.T) {
|
||||
value, err := NewUUID()
|
||||
if err != nil {
|
||||
t.Fatalf("NewUUID: %v", err)
|
||||
}
|
||||
if len(value) != 36 {
|
||||
t.Fatalf("uuid length = %d", len(value))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// NewUUID returns a random RFC 4122 version-4 UUID string.
|
||||
func NewUUID() (string, error) {
|
||||
buffer := make([]byte, 16)
|
||||
if _, err := rand.Read(buffer); err != nil {
|
||||
return "", fmt.Errorf("uuid: %w", err)
|
||||
}
|
||||
buffer[6] = (buffer[6] & 0x0f) | 0x40
|
||||
buffer[8] = (buffer[8] & 0x3f) | 0x80
|
||||
return fmt.Sprintf(
|
||||
"%x-%x-%x-%x-%x",
|
||||
buffer[0:4],
|
||||
buffer[4:6],
|
||||
buffer[6:8],
|
||||
buffer[8:10],
|
||||
buffer[10:16],
|
||||
), nil
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// LoadNodes reads and parses nodes.json from dir.
|
||||
func LoadNodes(dir string) (NodeStore, error) {
|
||||
path := filepath.Join(dir, NodesFileName)
|
||||
payload, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return NodeStore{}, err
|
||||
}
|
||||
|
||||
var store NodeStore
|
||||
if err := json.Unmarshal(payload, &store); err != nil {
|
||||
return NodeStore{}, fmt.Errorf("parse nodes: %w", err)
|
||||
}
|
||||
if store.Nodes == nil {
|
||||
store.Nodes = []Node{}
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// LoadNodesOrEmpty returns an empty store when nodes.json is missing.
|
||||
func LoadNodesOrEmpty(dir string) (NodeStore, error) {
|
||||
store, err := LoadNodes(dir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return NodeStore{Nodes: []Node{}}, nil
|
||||
}
|
||||
return NodeStore{}, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// SaveNodes writes nodes.json to dir with mode 0640.
|
||||
func SaveNodes(dir string, store NodeStore) error {
|
||||
if err := ensureConfigDir(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
if store.Nodes == nil {
|
||||
store.Nodes = []Node{}
|
||||
}
|
||||
|
||||
payload, err := json.MarshalIndent(store, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode nodes: %w", err)
|
||||
}
|
||||
payload = append(payload, '\n')
|
||||
|
||||
path := filepath.Join(dir, NodesFileName)
|
||||
if err := os.WriteFile(path, payload, 0o640); err != nil {
|
||||
return fmt.Errorf("write nodes: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadNodeKeys decrypts node-keys.enc from dir using key.
|
||||
func LoadNodeKeys(dir string, key []byte) (NodeKeyStore, error) {
|
||||
var store NodeKeyStore
|
||||
if err := loadEncryptedJSON(dir, NodeKeysFileName, key, &store); err != nil {
|
||||
return NodeKeyStore{}, err
|
||||
}
|
||||
if store.Keys == nil {
|
||||
store.Keys = []NodeKeyEntry{}
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// LoadNodeKeysOrEmpty returns an empty store when node-keys.enc is missing.
|
||||
func LoadNodeKeysOrEmpty(dir string, key []byte) (NodeKeyStore, error) {
|
||||
store, err := LoadNodeKeys(dir, key)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return NodeKeyStore{Keys: []NodeKeyEntry{}}, nil
|
||||
}
|
||||
return NodeKeyStore{}, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// SaveNodeKeys encrypts the node key store and writes node-keys.enc.
|
||||
func SaveNodeKeys(dir string, store NodeKeyStore, key []byte) error {
|
||||
if store.Keys == nil {
|
||||
store.Keys = []NodeKeyEntry{}
|
||||
}
|
||||
return saveEncryptedJSON(dir, NodeKeysFileName, key, store)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNodesAndNodeKeysRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
keyBytes := make([]byte, 32)
|
||||
for index := range keyBytes {
|
||||
keyBytes[index] = byte(index + 3)
|
||||
}
|
||||
t.Setenv(ConfigKeyEnvVar, base64.StdEncoding.EncodeToString(keyBytes))
|
||||
|
||||
store := NodeStore{
|
||||
Nodes: []Node{
|
||||
{
|
||||
ID: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
|
||||
Kind: NodeKindContainer,
|
||||
Name: "ct1",
|
||||
HostIP: "127.0.0.1",
|
||||
Username: "clustercanvas",
|
||||
GroupName: AdministratorsGroupName,
|
||||
PublicKey: "ssh-ed25519 AAAA",
|
||||
KeyAlgo: "ed25519",
|
||||
CreatedAt: time.Now().UTC(),
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := SaveNodes(dir, store); err != nil {
|
||||
t.Fatalf("SaveNodes: %v", err)
|
||||
}
|
||||
loaded, err := LoadNodes(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadNodes: %v", err)
|
||||
}
|
||||
if len(loaded.Nodes) != 1 || loaded.Nodes[0].Name != "ct1" {
|
||||
t.Fatalf("loaded = %+v", loaded)
|
||||
}
|
||||
|
||||
keyStore := NodeKeyStore{
|
||||
Keys: []NodeKeyEntry{
|
||||
{
|
||||
NodeID: loaded.Nodes[0].ID,
|
||||
PrivateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n-----END OPENSSH PRIVATE KEY-----\n",
|
||||
Algorithm: "ed25519",
|
||||
KDFRounds: 100,
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := SaveNodeKeys(dir, keyStore, keyBytes); err != nil {
|
||||
t.Fatalf("SaveNodeKeys: %v", err)
|
||||
}
|
||||
loadedKeys, err := LoadNodeKeys(dir, keyBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadNodeKeys: %v", err)
|
||||
}
|
||||
if len(loadedKeys.Keys) != 1 || loadedKeys.Keys[0].KDFRounds != 100 {
|
||||
t.Fatalf("keys = %+v", loadedKeys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadNodesOrEmptyMissing(t *testing.T) {
|
||||
store, err := LoadNodesOrEmpty(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("LoadNodesOrEmpty: %v", err)
|
||||
}
|
||||
if len(store.Nodes) != 0 {
|
||||
t.Fatalf("expected empty, got %d", len(store.Nodes))
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ const (
|
||||
SecretsFileName = "secrets.enc"
|
||||
PasswordsFileName = "passwords.enc"
|
||||
SessionsFileName = "sessions.enc"
|
||||
NodesFileName = "nodes.json"
|
||||
NodeKeysFileName = "node-keys.enc"
|
||||
)
|
||||
|
||||
// ResolveDir returns the config directory using precedence:
|
||||
|
||||
@@ -85,6 +85,48 @@ type Secrets struct {
|
||||
// Placeholder until Proxmox/API secrets exist.
|
||||
}
|
||||
|
||||
// NodeKind identifies which left-nav category a managed host belongs to.
|
||||
type NodeKind string
|
||||
|
||||
const (
|
||||
NodeKindContainer NodeKind = "container"
|
||||
NodeKindVM NodeKind = "vm"
|
||||
NodeKindDocker NodeKind = "docker"
|
||||
)
|
||||
|
||||
// Node is a remote host ClusterCanvas manages over SSH.
|
||||
type Node struct {
|
||||
ID string `json:"id"`
|
||||
Kind NodeKind `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
HostIP string `json:"host_ip"`
|
||||
Username string `json:"username"`
|
||||
GroupName string `json:"group_name"`
|
||||
PublicKey string `json:"public_key"`
|
||||
KeyAlgo string `json:"key_algo"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// NodeStore is the plain JSON payload in nodes.json.
|
||||
type NodeStore struct {
|
||||
Nodes []Node `json:"nodes"`
|
||||
}
|
||||
|
||||
// NodeKeyEntry holds private key material for one node, keyed by node UUID.
|
||||
type NodeKeyEntry struct {
|
||||
NodeID string `json:"node_id"`
|
||||
PrivateKey string `json:"private_key"`
|
||||
Passphrase string `json:"passphrase,omitempty"`
|
||||
Algorithm string `json:"algorithm"`
|
||||
RSABits int `json:"rsa_bits,omitempty"`
|
||||
KDFRounds int `json:"kdf_rounds,omitempty"`
|
||||
}
|
||||
|
||||
// NodeKeyStore is the plaintext JSON payload inside node-keys.enc.
|
||||
type NodeKeyStore struct {
|
||||
Keys []NodeKeyEntry `json:"keys"`
|
||||
}
|
||||
|
||||
// UserCredential is one account stored in passwords.enc.
|
||||
type UserCredential struct {
|
||||
ID string `json:"id"`
|
||||
@@ -105,11 +147,12 @@ type PasswordStore struct {
|
||||
|
||||
// SessionRecord is one server-side session stored in sessions.enc.
|
||||
type SessionRecord struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastSeenAt time.Time `json:"last_seen_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastSeenAt time.Time `json:"last_seen_at"`
|
||||
LastReauthAt time.Time `json:"last_reauth_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// SessionStore is the plaintext JSON payload inside sessions.enc.
|
||||
|
||||
Reference in New Issue
Block a user