795 lines
23 KiB
Go
795 lines
23 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"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",
|
|
})
|
|
}
|
|
|
|
type patchNodeRequest struct {
|
|
HealthCheckIntervalSeconds *int `json:"health_check_interval_seconds"`
|
|
}
|
|
|
|
func (app *App) nodesPatchHandler(writer http.ResponseWriter, request *http.Request) {
|
|
if err := app.authorizeNodesUpdate(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
|
|
}
|
|
|
|
var payload patchNodeRequest
|
|
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
|
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
|
|
return
|
|
}
|
|
if payload.HealthCheckIntervalSeconds == nil {
|
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{
|
|
Error: "health_check_interval_seconds is required",
|
|
})
|
|
return
|
|
}
|
|
if *payload.HealthCheckIntervalSeconds < 0 {
|
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{
|
|
Error: "health_check_interval_seconds must be >= 0",
|
|
})
|
|
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
|
|
}
|
|
|
|
nodeIndex := -1
|
|
var node settings.Node
|
|
for index, candidate := range store.Nodes {
|
|
if candidate.ID != nodeID {
|
|
continue
|
|
}
|
|
node = candidate
|
|
nodeIndex = index
|
|
break
|
|
}
|
|
if nodeIndex < 0 {
|
|
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
|
|
}
|
|
|
|
node.HealthCheckIntervalSeconds = *payload.HealthCheckIntervalSeconds
|
|
store.Nodes[nodeIndex] = node
|
|
if err := settings.SaveNodes(app.ConfigDir, store); err != nil {
|
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
_ = appendNodeAudit(
|
|
app.ConfigDir,
|
|
settings.NodeAuditActionUpdate,
|
|
user.Username,
|
|
node,
|
|
fmt.Sprintf("health_check_interval_seconds=%d", node.HealthCheckIntervalSeconds),
|
|
)
|
|
|
|
if node.HealthCheckIntervalSeconds > 0 && app.HealthChecker != nil {
|
|
nodeID := node.ID
|
|
actor := user.Username
|
|
go func() {
|
|
_, _ = app.HealthChecker.CheckNodeByID(nodeID, actor)
|
|
}()
|
|
}
|
|
|
|
writeJSON(writer, http.StatusOK, nodeResponse{Node: node})
|
|
}
|
|
|
|
func (app *App) nodesHealthCheckHandler(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
|
|
}
|
|
if app.HealthChecker == nil {
|
|
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: "health checker unavailable"})
|
|
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
|
|
}
|
|
|
|
updated, err := app.HealthChecker.CheckNodeByID(nodeID, user.Username)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "already running") {
|
|
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
writeJSON(writer, http.StatusOK, nodeResponse{Node: updated})
|
|
}
|
|
|
|
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
|
|
}
|