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:
2026-07-19 22:00:14 +02:00
parent 0e06063c1d
commit 7631591f30
22 changed files with 2685 additions and 274 deletions
@@ -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,
})
}