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.
143 lines
3.6 KiB
Go
143 lines
3.6 KiB
Go
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,
|
|
})
|
|
}
|