package api import ( "encoding/json" "net/http" "strings" "time" "codeberg.org/SquidSE/ClusterCanvas/service/internal/auth" "codeberg.org/SquidSE/ClusterCanvas/service/internal/settings" ) type resolveCommandsRequest struct { Names []string `json:"names"` } type resolveCommandsResponse struct { Paths map[string]string `json:"paths"` Missing []string `json:"missing"` } const resolveCommandsTimeout = 30 * time.Second func (app *App) nodesResolveCommandsHandler(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 } var payload resolveCommandsRequest if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"}) return } names, err := auth.ValidateCommandNames(payload.Names) 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 } 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 } remoteCommand := auth.BuildResolveCommandsRemote(names) result, runErr := auth.RunSSHShell( node.HostIP, node.Username, keyEntry.PrivateKey, keyEntry.Passphrase, remoteCommand, nil, resolveCommandsTimeout, ) if runErr != nil { writeJSON(writer, http.StatusBadGateway, apiErrorResponse{Error: runErr.Error()}) return } if result.ExitCode != 0 { message := strings.TrimSpace(result.Stderr) if message == "" { message = "resolve-commands failed on node" } writeJSON(writer, http.StatusBadGateway, apiErrorResponse{Error: message}) return } paths, missing := auth.ParseResolveCommandsOutput(names, result.Stdout) if missing == nil { missing = []string{} } writeJSON(writer, http.StatusOK, resolveCommandsResponse{ Paths: paths, Missing: missing, }) }