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"` } type deleteNodeRequest struct { CurrentPassword string `json:"current_password"` TOTPCode string `json:"totp_code"` } type nodeLogsResponse struct { Events []settings.NodeAuditEvent `json:"events"` } 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 { _ = appendNodeAudit(app.ConfigDir, settings.NodeAuditActionTestSSH, user.Username, node, err.Error()) writeJSON(writer, http.StatusOK, nodeSSHTestResponse{ OK: false, Message: err.Error(), }) return } _ = appendNodeAudit(app.ConfigDir, settings.NodeAuditActionTestSSH, user.Username, node, "SSH connection succeeded") 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 } actorUsername := "" if actor, ok := UserFromContext(request.Context()); ok { actorUsername = actor.Username } _ = appendNodeAudit(app.ConfigDir, settings.NodeAuditActionCreate, actorUsername, node, "") writeJSON(writer, http.StatusCreated, nodeResponse{Node: node}) } func (app *App) nodesDeleteHandler(writer http.ResponseWriter, request *http.Request) { if err := app.authorizeNodesDelete(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 } var payload deleteNodeRequest 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 } store, err := settings.LoadNodesOrEmpty(app.ConfigDir) if err != nil { writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) return } var deleted settings.Node remaining := make([]settings.Node, 0, len(store.Nodes)) found := false for _, candidate := range store.Nodes { if candidate.ID != nodeID { remaining = append(remaining, candidate) continue } found = true deleted = candidate if !userCanAccessNode(user, settingsPayload.Groups, candidate) { writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: "permission denied"}) return } } if !found { writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "node not found"}) return } keyStore, err := settings.LoadNodeKeysOrEmpty(app.ConfigDir, app.Key) if err != nil { writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) return } remainingKeys := make([]settings.NodeKeyEntry, 0, len(keyStore.Keys)) for _, entry := range keyStore.Keys { if entry.NodeID == nodeID { continue } remainingKeys = append(remainingKeys, entry) } store.Nodes = remaining keyStore.Keys = remainingKeys 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 } _ = appendNodeAudit(app.ConfigDir, settings.NodeAuditActionDelete, user.Username, deleted, "") filtered := make([]settings.Node, 0, len(store.Nodes)) for _, node := range store.Nodes { if userCanAccessNode(user, settingsPayload.Groups, node) { filtered = append(filtered, node) } } writeJSON(writer, http.StatusOK, nodesResponse{Nodes: filtered}) } func (app *App) nodeLogsListHandler(writer http.ResponseWriter, request *http.Request) { if err := app.authorizeLogsRead(request); err != nil { writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()}) return } store, err := settings.LoadNodeAuditOrEmpty(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.NodeAuditEvent, 0, len(store.Events)) for index := len(store.Events) - 1; index >= 0; index-- { event := store.Events[index] if kindFilter != "" && string(event.NodeKind) != kindFilter { continue } filtered = append(filtered, event) } writeJSON(writer, http.StatusOK, nodeLogsResponse{Events: filtered}) } 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 appendNodeAudit( configDir string, action settings.NodeAuditAction, actor string, node settings.Node, detail string, ) error { eventID, err := auth.NewUUID() if err != nil { return err } return settings.AppendNodeAuditEvent(configDir, settings.NodeAuditEvent{ ID: eventID, At: time.Now().UTC(), Action: action, Actor: actor, NodeID: node.ID, NodeName: node.Name, NodeKind: node.Kind, Detail: detail, }) } 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 }