Wire action-group variables, sudo, and command path resolution through the API and UI.
Completes run-if/set_variable and requires_sudo editing, sudoers path resolve helpers, and execute permission checks so node action groups can gate upgrades and elevate safely.
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user