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,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
|
||||
}
|
||||
Reference in New Issue
Block a user