Files
ClusterCanvas/service/internal/api/nodes_handlers.go
T
Squid f4dc8f63d7 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.
2026-07-18 16:39:10 +02:00

461 lines
13 KiB
Go

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
}