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:
@@ -9,6 +9,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
|
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
|
||||||
|
"codeberg.org/SquidSE/ClusterCanvas/service/internal/runner"
|
||||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -50,14 +51,20 @@ type createActionGroupItemRequest struct {
|
|||||||
Kind settings.ActionKind `json:"kind"`
|
Kind settings.ActionKind `json:"kind"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
Env []settings.ActionEnvVar `json:"env"`
|
Env []settings.ActionEnvVar `json:"env"`
|
||||||
|
RequiresSudo bool `json:"requires_sudo"`
|
||||||
|
SetVariable string `json:"set_variable"`
|
||||||
|
RunIf string `json:"run_if"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type patchActionGroupItemRequest struct {
|
type patchActionGroupItemRequest struct {
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
Kind *settings.ActionKind `json:"kind"`
|
Kind *settings.ActionKind `json:"kind"`
|
||||||
Body *string `json:"body"`
|
Body *string `json:"body"`
|
||||||
Env *[]settings.ActionEnvVar `json:"env"`
|
Env *[]settings.ActionEnvVar `json:"env"`
|
||||||
|
RequiresSudo *bool `json:"requires_sudo"`
|
||||||
|
SetVariable *string `json:"set_variable"`
|
||||||
|
RunIf *string `json:"run_if"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type reorderActionGroupItemsRequest struct {
|
type reorderActionGroupItemsRequest struct {
|
||||||
@@ -325,32 +332,55 @@ func (app *App) actionGroupItemsPatchHandler(writer http.ResponseWriter, request
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
item := store.Groups[groupIndex].Items[itemIndex]
|
item := store.Groups[groupIndex].Items[itemIndex]
|
||||||
if item.Source != settings.ActionItemSourceLocal {
|
editingBodyFields := payload.Name != nil || payload.Description != nil || payload.Kind != nil || payload.Body != nil || payload.Env != nil || payload.RequiresSudo != nil
|
||||||
|
if item.Source != settings.ActionItemSourceLocal && editingBodyFields {
|
||||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "library actions must be cloned before editing"})
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "library actions must be cloned before editing"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if payload.Name != nil {
|
if item.Source == settings.ActionItemSourceLocal {
|
||||||
item.Name = strings.TrimSpace(*payload.Name)
|
if payload.Name != nil {
|
||||||
|
item.Name = strings.TrimSpace(*payload.Name)
|
||||||
|
}
|
||||||
|
if payload.Description != nil {
|
||||||
|
item.Description = strings.TrimSpace(*payload.Description)
|
||||||
|
}
|
||||||
|
if payload.Kind != nil {
|
||||||
|
item.Kind = *payload.Kind
|
||||||
|
}
|
||||||
|
if payload.Body != nil {
|
||||||
|
item.Body = strings.TrimSpace(*payload.Body)
|
||||||
|
}
|
||||||
|
if payload.Env != nil {
|
||||||
|
item.Env = *payload.Env
|
||||||
|
}
|
||||||
|
if payload.RequiresSudo != nil {
|
||||||
|
item.RequiresSudo = *payload.RequiresSudo
|
||||||
|
}
|
||||||
|
if item.Env == nil {
|
||||||
|
item.Env = []settings.ActionEnvVar{}
|
||||||
|
}
|
||||||
|
if err := validateActionFields(item.Name, item.Kind, item.Body, item.Env); err != nil {
|
||||||
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if payload.Description != nil {
|
|
||||||
item.Description = strings.TrimSpace(*payload.Description)
|
if payload.SetVariable != nil {
|
||||||
|
setVariable, err := normalizeSetVariable(*payload.SetVariable)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item.SetVariable = setVariable
|
||||||
}
|
}
|
||||||
if payload.Kind != nil {
|
if payload.RunIf != nil {
|
||||||
item.Kind = *payload.Kind
|
runIf, err := normalizeRunIf(*payload.RunIf)
|
||||||
}
|
if err != nil {
|
||||||
if payload.Body != nil {
|
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
||||||
item.Body = strings.TrimSpace(*payload.Body)
|
return
|
||||||
}
|
}
|
||||||
if payload.Env != nil {
|
item.RunIf = runIf
|
||||||
item.Env = *payload.Env
|
|
||||||
}
|
|
||||||
if item.Env == nil {
|
|
||||||
item.Env = []settings.ActionEnvVar{}
|
|
||||||
}
|
|
||||||
if err := validateActionFields(item.Name, item.Kind, item.Body, item.Env); err != nil {
|
|
||||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
store.Groups[groupIndex].Items[itemIndex] = item
|
store.Groups[groupIndex].Items[itemIndex] = item
|
||||||
@@ -425,6 +455,7 @@ func (app *App) actionGroupItemsCloneHandler(writer http.ResponseWriter, request
|
|||||||
item.Kind = libraryAction.Kind
|
item.Kind = libraryAction.Kind
|
||||||
item.Body = libraryAction.Body
|
item.Body = libraryAction.Body
|
||||||
item.Env = env
|
item.Env = env
|
||||||
|
item.RequiresSudo = libraryAction.RequiresSudo
|
||||||
|
|
||||||
store.Groups[groupIndex].Items[itemIndex] = item
|
store.Groups[groupIndex].Items[itemIndex] = item
|
||||||
store.Groups[groupIndex].UpdatedAt = time.Now().UTC()
|
store.Groups[groupIndex].UpdatedAt = time.Now().UTC()
|
||||||
@@ -534,7 +565,7 @@ func (app *App) actionGroupItemsOrderHandler(writer http.ResponseWriter, request
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) actionGroupsRunHandler(writer http.ResponseWriter, request *http.Request) {
|
func (app *App) actionGroupsRunHandler(writer http.ResponseWriter, request *http.Request) {
|
||||||
if err := app.authorizeJobsRun(request); err != nil {
|
if err := authorizeActionsExecute(request, app.ConfigDir); err != nil {
|
||||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -565,7 +596,7 @@ func (app *App) actionGroupsRunHandler(writer http.ResponseWriter, request *http
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) actionGroupItemsRunHandler(writer http.ResponseWriter, request *http.Request) {
|
func (app *App) actionGroupItemsRunHandler(writer http.ResponseWriter, request *http.Request) {
|
||||||
if err := app.authorizeJobsRun(request); err != nil {
|
if err := authorizeActionsExecute(request, app.ConfigDir); err != nil {
|
||||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -700,6 +731,14 @@ func buildNodeActionItem(payload createActionGroupItemRequest) (settings.NodeAct
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return settings.NodeActionItem{}, err
|
return settings.NodeActionItem{}, err
|
||||||
}
|
}
|
||||||
|
setVariable, err := normalizeSetVariable(payload.SetVariable)
|
||||||
|
if err != nil {
|
||||||
|
return settings.NodeActionItem{}, err
|
||||||
|
}
|
||||||
|
runIf, err := normalizeRunIf(payload.RunIf)
|
||||||
|
if err != nil {
|
||||||
|
return settings.NodeActionItem{}, err
|
||||||
|
}
|
||||||
switch payload.Source {
|
switch payload.Source {
|
||||||
case settings.ActionItemSourceLibrary:
|
case settings.ActionItemSourceLibrary:
|
||||||
libraryID := strings.TrimSpace(payload.LibraryActionID)
|
libraryID := strings.TrimSpace(payload.LibraryActionID)
|
||||||
@@ -711,6 +750,8 @@ func buildNodeActionItem(payload createActionGroupItemRequest) (settings.NodeAct
|
|||||||
Source: settings.ActionItemSourceLibrary,
|
Source: settings.ActionItemSourceLibrary,
|
||||||
LibraryActionID: libraryID,
|
LibraryActionID: libraryID,
|
||||||
Env: []settings.ActionEnvVar{},
|
Env: []settings.ActionEnvVar{},
|
||||||
|
SetVariable: setVariable,
|
||||||
|
RunIf: runIf,
|
||||||
}, nil
|
}, nil
|
||||||
case settings.ActionItemSourceLocal:
|
case settings.ActionItemSourceLocal:
|
||||||
env := payload.Env
|
env := payload.Env
|
||||||
@@ -723,19 +764,48 @@ func buildNodeActionItem(payload createActionGroupItemRequest) (settings.NodeAct
|
|||||||
return settings.NodeActionItem{}, err
|
return settings.NodeActionItem{}, err
|
||||||
}
|
}
|
||||||
return settings.NodeActionItem{
|
return settings.NodeActionItem{
|
||||||
ID: itemID,
|
ID: itemID,
|
||||||
Source: settings.ActionItemSourceLocal,
|
Source: settings.ActionItemSourceLocal,
|
||||||
Name: name,
|
Name: name,
|
||||||
Description: strings.TrimSpace(payload.Description),
|
Description: strings.TrimSpace(payload.Description),
|
||||||
Kind: payload.Kind,
|
Kind: payload.Kind,
|
||||||
Body: body,
|
Body: body,
|
||||||
Env: env,
|
Env: env,
|
||||||
|
RequiresSudo: payload.RequiresSudo,
|
||||||
|
SetVariable: setVariable,
|
||||||
|
RunIf: runIf,
|
||||||
}, nil
|
}, nil
|
||||||
default:
|
default:
|
||||||
return settings.NodeActionItem{}, errors.New("source must be library or local")
|
return settings.NodeActionItem{}, errors.New("source must be library or local")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeSetVariable(raw string) (string, error) {
|
||||||
|
trimmed := strings.TrimSpace(raw)
|
||||||
|
if trimmed == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(trimmed, "$") {
|
||||||
|
trimmed = strings.TrimPrefix(trimmed, "$")
|
||||||
|
trimmed = strings.TrimSpace(trimmed)
|
||||||
|
}
|
||||||
|
if err := runner.ValidateVariableName(trimmed); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return trimmed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRunIf(raw string) (string, error) {
|
||||||
|
trimmed := strings.TrimSpace(raw)
|
||||||
|
if trimmed == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if err := runner.ValidateCondition(trimmed); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return trimmed, nil
|
||||||
|
}
|
||||||
|
|
||||||
func validateSchedule(schedule settings.ActionGroupSchedule) error {
|
func validateSchedule(schedule settings.ActionGroupSchedule) error {
|
||||||
switch schedule.Kind {
|
switch schedule.Kind {
|
||||||
case settings.ActionGroupScheduleInterval:
|
case settings.ActionGroupScheduleInterval:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -168,6 +169,9 @@ func TestActionGroupsCRUDCloneReorderAndAuthz(t *testing.T) {
|
|||||||
if clonedItem.Source != settings.ActionItemSourceLocal || clonedItem.Name != "Uptime" {
|
if clonedItem.Source != settings.ActionItemSourceLocal || clonedItem.Name != "Uptime" {
|
||||||
t.Fatalf("cloned item = %#v", clonedItem)
|
t.Fatalf("cloned item = %#v", clonedItem)
|
||||||
}
|
}
|
||||||
|
if clonedItem.RequiresSudo {
|
||||||
|
t.Fatalf("builtin uptime clone should not require sudo: %#v", clonedItem)
|
||||||
|
}
|
||||||
|
|
||||||
// Forbidden without actions.create for a limited user session is covered below via groups.
|
// Forbidden without actions.create for a limited user session is covered below via groups.
|
||||||
listReq := withSession(
|
listReq := withSession(
|
||||||
@@ -191,6 +195,218 @@ func TestActionGroupsCRUDCloneReorderAndAuthz(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestActionGroupItemVariablesCreateAndPatch(t *testing.T) {
|
||||||
|
configDir := t.TempDir()
|
||||||
|
cookie := seedCompletedSetup(t, configDir)
|
||||||
|
node := seedNodeForActions(t, configDir)
|
||||||
|
if _, err := settings.LoadActionsOrSeed(configDir); err != nil {
|
||||||
|
t.Fatalf("LoadActionsOrSeed: %v", err)
|
||||||
|
}
|
||||||
|
router := NewRouter(configDir)
|
||||||
|
|
||||||
|
createBody, _ := json.Marshal(map[string]any{
|
||||||
|
"name": "Vars",
|
||||||
|
"schedule": map[string]any{
|
||||||
|
"enabled": false,
|
||||||
|
"kind": "interval",
|
||||||
|
"times": []any{},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
createReq := withSession(
|
||||||
|
httptest.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
"/api/v1/nodes/"+node.ID+"/action-groups",
|
||||||
|
bytes.NewReader(createBody),
|
||||||
|
),
|
||||||
|
cookie,
|
||||||
|
)
|
||||||
|
createRec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(createRec, createReq)
|
||||||
|
if createRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("create group status=%d body=%s", createRec.Code, createRec.Body.String())
|
||||||
|
}
|
||||||
|
var created actionGroupResponse
|
||||||
|
if err := json.Unmarshal(createRec.Body.Bytes(), &created); err != nil {
|
||||||
|
t.Fatalf("decode create: %v", err)
|
||||||
|
}
|
||||||
|
groupID := created.Group.ID
|
||||||
|
|
||||||
|
addLocalBody, _ := json.Marshal(map[string]any{
|
||||||
|
"source": "local",
|
||||||
|
"name": "Count",
|
||||||
|
"kind": "shell",
|
||||||
|
"body": "echo 3",
|
||||||
|
"env": []any{},
|
||||||
|
"set_variable": "UpdateCount",
|
||||||
|
"run_if": "",
|
||||||
|
})
|
||||||
|
addLocalReq := withSession(
|
||||||
|
httptest.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items",
|
||||||
|
bytes.NewReader(addLocalBody),
|
||||||
|
),
|
||||||
|
cookie,
|
||||||
|
)
|
||||||
|
addLocalRec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(addLocalRec, addLocalReq)
|
||||||
|
if addLocalRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("add local status=%d body=%s", addLocalRec.Code, addLocalRec.Body.String())
|
||||||
|
}
|
||||||
|
var withLocal actionGroupResponse
|
||||||
|
if err := json.Unmarshal(addLocalRec.Body.Bytes(), &withLocal); err != nil {
|
||||||
|
t.Fatalf("decode local: %v", err)
|
||||||
|
}
|
||||||
|
if withLocal.Group.Items[0].SetVariable != "UpdateCount" {
|
||||||
|
t.Fatalf("set_variable = %#v", withLocal.Group.Items[0])
|
||||||
|
}
|
||||||
|
localID := withLocal.Group.Items[0].ID
|
||||||
|
|
||||||
|
addLibBody, _ := json.Marshal(map[string]any{
|
||||||
|
"source": "library",
|
||||||
|
"library_action_id": settings.BuiltinActionUptimeID,
|
||||||
|
"set_variable": "$Bad-Name",
|
||||||
|
})
|
||||||
|
badLibReq := withSession(
|
||||||
|
httptest.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items",
|
||||||
|
bytes.NewReader(addLibBody),
|
||||||
|
),
|
||||||
|
cookie,
|
||||||
|
)
|
||||||
|
badLibRec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(badLibRec, badLibReq)
|
||||||
|
if badLibRec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected bad variable name, got %d body=%s", badLibRec.Code, badLibRec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
addLibOKBody, _ := json.Marshal(map[string]any{
|
||||||
|
"source": "library",
|
||||||
|
"library_action_id": settings.BuiltinActionUptimeID,
|
||||||
|
})
|
||||||
|
addLibReq := withSession(
|
||||||
|
httptest.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items",
|
||||||
|
bytes.NewReader(addLibOKBody),
|
||||||
|
),
|
||||||
|
cookie,
|
||||||
|
)
|
||||||
|
addLibRec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(addLibRec, addLibReq)
|
||||||
|
if addLibRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("add library status=%d body=%s", addLibRec.Code, addLibRec.Body.String())
|
||||||
|
}
|
||||||
|
var withLib actionGroupResponse
|
||||||
|
_ = json.Unmarshal(addLibRec.Body.Bytes(), &withLib)
|
||||||
|
var libID string
|
||||||
|
for _, item := range withLib.Group.Items {
|
||||||
|
if item.Source == settings.ActionItemSourceLibrary {
|
||||||
|
libID = item.ID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if libID == "" {
|
||||||
|
t.Fatal("library item missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
patchLibBody, _ := json.Marshal(map[string]any{
|
||||||
|
"run_if": "if $UpdateCount >= 1",
|
||||||
|
"set_variable": "UptimeOut",
|
||||||
|
})
|
||||||
|
patchLibReq := withSession(
|
||||||
|
httptest.NewRequest(
|
||||||
|
http.MethodPatch,
|
||||||
|
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items/"+libID,
|
||||||
|
bytes.NewReader(patchLibBody),
|
||||||
|
),
|
||||||
|
cookie,
|
||||||
|
)
|
||||||
|
patchLibRec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(patchLibRec, patchLibReq)
|
||||||
|
if patchLibRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("patch library vars status=%d body=%s", patchLibRec.Code, patchLibRec.Body.String())
|
||||||
|
}
|
||||||
|
var patchedLib actionGroupResponse
|
||||||
|
_ = json.Unmarshal(patchLibRec.Body.Bytes(), &patchedLib)
|
||||||
|
var libItem settings.NodeActionItem
|
||||||
|
for _, item := range patchedLib.Group.Items {
|
||||||
|
if item.ID == libID {
|
||||||
|
libItem = item
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if libItem.RunIf != "if $UpdateCount >= 1" || libItem.SetVariable != "UptimeOut" {
|
||||||
|
t.Fatalf("library vars = %#v", libItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
badBodyPatch, _ := json.Marshal(map[string]any{
|
||||||
|
"body": "echo no",
|
||||||
|
})
|
||||||
|
badBodyReq := withSession(
|
||||||
|
httptest.NewRequest(
|
||||||
|
http.MethodPatch,
|
||||||
|
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items/"+libID,
|
||||||
|
bytes.NewReader(badBodyPatch),
|
||||||
|
),
|
||||||
|
cookie,
|
||||||
|
)
|
||||||
|
badBodyRec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(badBodyRec, badBodyReq)
|
||||||
|
if badBodyRec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected clone-required for body edit, got %d", badBodyRec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
badCondBody, _ := json.Marshal(map[string]any{
|
||||||
|
"run_if": "not valid",
|
||||||
|
})
|
||||||
|
badCondReq := withSession(
|
||||||
|
httptest.NewRequest(
|
||||||
|
http.MethodPatch,
|
||||||
|
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items/"+localID,
|
||||||
|
bytes.NewReader(badCondBody),
|
||||||
|
),
|
||||||
|
cookie,
|
||||||
|
)
|
||||||
|
badCondRec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(badCondRec, badCondReq)
|
||||||
|
if badCondRec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected bad run_if, got %d body=%s", badCondRec.Code, badCondRec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
sudoLocalBody, _ := json.Marshal(map[string]any{
|
||||||
|
"requires_sudo": true,
|
||||||
|
})
|
||||||
|
sudoLocalReq := withSession(
|
||||||
|
httptest.NewRequest(
|
||||||
|
http.MethodPatch,
|
||||||
|
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items/"+localID,
|
||||||
|
bytes.NewReader(sudoLocalBody),
|
||||||
|
),
|
||||||
|
cookie,
|
||||||
|
)
|
||||||
|
sudoLocalRec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(sudoLocalRec, sudoLocalReq)
|
||||||
|
if sudoLocalRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("patch requires_sudo status=%d body=%s", sudoLocalRec.Code, sudoLocalRec.Body.String())
|
||||||
|
}
|
||||||
|
var withSudo actionGroupResponse
|
||||||
|
_ = json.Unmarshal(sudoLocalRec.Body.Bytes(), &withSudo)
|
||||||
|
foundSudo := false
|
||||||
|
for _, item := range withSudo.Group.Items {
|
||||||
|
if item.ID == localID {
|
||||||
|
foundSudo = true
|
||||||
|
if !item.RequiresSudo {
|
||||||
|
t.Fatalf("expected requires_sudo on local item, got %#v", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundSudo {
|
||||||
|
t.Fatal("local item missing after sudo patch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestActionGroupsForbiddenWithoutPermission(t *testing.T) {
|
func TestActionGroupsForbiddenWithoutPermission(t *testing.T) {
|
||||||
configDir := t.TempDir()
|
configDir := t.TempDir()
|
||||||
cookie := seedCompletedSetup(t, configDir)
|
cookie := seedCompletedSetup(t, configDir)
|
||||||
@@ -222,3 +438,178 @@ func TestActionGroupsForbiddenWithoutPermission(t *testing.T) {
|
|||||||
t.Fatalf("expected forbidden, got %d body=%s", rec.Code, rec.Body.String())
|
t.Fatalf("expected forbidden, got %d body=%s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestActionGroupRunRequiresActionsExecute(t *testing.T) {
|
||||||
|
configDir := t.TempDir()
|
||||||
|
cookie := seedCompletedSetup(t, configDir)
|
||||||
|
node := seedNodeForActions(t, configDir)
|
||||||
|
if _, err := settings.LoadActionsOrSeed(configDir); err != nil {
|
||||||
|
t.Fatalf("LoadActionsOrSeed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a group while still admin, then strip execute (+ jobs.run so
|
||||||
|
// migration does not re-grant actions.execute).
|
||||||
|
router := NewRouter(configDir)
|
||||||
|
createBody, _ := json.Marshal(map[string]any{
|
||||||
|
"name": "RunAuth",
|
||||||
|
"schedule": map[string]any{
|
||||||
|
"enabled": false,
|
||||||
|
"kind": "interval",
|
||||||
|
"times": []any{},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
createReq := withSession(
|
||||||
|
httptest.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
"/api/v1/nodes/"+node.ID+"/action-groups",
|
||||||
|
bytes.NewReader(createBody),
|
||||||
|
),
|
||||||
|
cookie,
|
||||||
|
)
|
||||||
|
createRec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(createRec, createReq)
|
||||||
|
if createRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("create group status=%d body=%s", createRec.Code, createRec.Body.String())
|
||||||
|
}
|
||||||
|
var created actionGroupResponse
|
||||||
|
_ = json.Unmarshal(createRec.Body.Bytes(), &created)
|
||||||
|
|
||||||
|
payload, err := settings.LoadSettings(configDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadSettings: %v", err)
|
||||||
|
}
|
||||||
|
payload.Groups[0].Permissions = []string{"nodes.read", "actions.create", "actions.update"}
|
||||||
|
if err := settings.SaveSettings(configDir, payload); err != nil {
|
||||||
|
t.Fatalf("SaveSettings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
runReq := withSession(
|
||||||
|
httptest.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
"/api/v1/nodes/"+node.ID+"/action-groups/"+created.Group.ID+"/run",
|
||||||
|
nil,
|
||||||
|
),
|
||||||
|
cookie,
|
||||||
|
)
|
||||||
|
runRec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(runRec, runReq)
|
||||||
|
if runRec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("expected run forbidden without actions.execute, got %d body=%s", runRec.Code, runRec.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(runRec.Body.String(), "actions.execute") {
|
||||||
|
t.Fatalf("expected actions.execute in error, got %s", runRec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActionGroupCloneCopiesRequiresSudo(t *testing.T) {
|
||||||
|
configDir := t.TempDir()
|
||||||
|
cookie := seedCompletedSetup(t, configDir)
|
||||||
|
node := seedNodeForActions(t, configDir)
|
||||||
|
if _, err := settings.LoadActionsOrSeed(configDir); err != nil {
|
||||||
|
t.Fatalf("LoadActionsOrSeed: %v", err)
|
||||||
|
}
|
||||||
|
router := NewRouter(configDir)
|
||||||
|
|
||||||
|
createActionBody, _ := json.Marshal(map[string]any{
|
||||||
|
"name": "Restart svc",
|
||||||
|
"description": "",
|
||||||
|
"kind": "shell",
|
||||||
|
"body": "/usr/bin/systemctl restart myservice",
|
||||||
|
"env": []any{},
|
||||||
|
"requires_sudo": true,
|
||||||
|
})
|
||||||
|
createActionReq := withSession(
|
||||||
|
httptest.NewRequest(http.MethodPost, "/api/v1/actions", bytes.NewReader(createActionBody)),
|
||||||
|
cookie,
|
||||||
|
)
|
||||||
|
createActionRec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(createActionRec, createActionReq)
|
||||||
|
if createActionRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("create action status=%d body=%s", createActionRec.Code, createActionRec.Body.String())
|
||||||
|
}
|
||||||
|
var actionsPayload actionsResponseTest
|
||||||
|
if err := json.Unmarshal(createActionRec.Body.Bytes(), &actionsPayload); err != nil {
|
||||||
|
t.Fatalf("decode actions: %v", err)
|
||||||
|
}
|
||||||
|
var libraryID string
|
||||||
|
for _, action := range actionsPayload.Actions {
|
||||||
|
if action.Name == "Restart svc" {
|
||||||
|
libraryID = action.ID
|
||||||
|
if !action.RequiresSudo {
|
||||||
|
t.Fatalf("library action missing requires_sudo: %#v", action)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if libraryID == "" {
|
||||||
|
t.Fatal("library action not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
createGroupBody, _ := json.Marshal(map[string]any{
|
||||||
|
"name": "SudoGrp",
|
||||||
|
"schedule": map[string]any{
|
||||||
|
"enabled": false,
|
||||||
|
"kind": "interval",
|
||||||
|
"times": []any{},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
createGroupReq := withSession(
|
||||||
|
httptest.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
"/api/v1/nodes/"+node.ID+"/action-groups",
|
||||||
|
bytes.NewReader(createGroupBody),
|
||||||
|
),
|
||||||
|
cookie,
|
||||||
|
)
|
||||||
|
createGroupRec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(createGroupRec, createGroupReq)
|
||||||
|
if createGroupRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("create group status=%d", createGroupRec.Code)
|
||||||
|
}
|
||||||
|
var createdGroup actionGroupResponse
|
||||||
|
_ = json.Unmarshal(createGroupRec.Body.Bytes(), &createdGroup)
|
||||||
|
|
||||||
|
addItemBody, _ := json.Marshal(map[string]any{
|
||||||
|
"source": "library",
|
||||||
|
"library_action_id": libraryID,
|
||||||
|
})
|
||||||
|
addItemReq := withSession(
|
||||||
|
httptest.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
"/api/v1/nodes/"+node.ID+"/action-groups/"+createdGroup.Group.ID+"/items",
|
||||||
|
bytes.NewReader(addItemBody),
|
||||||
|
),
|
||||||
|
cookie,
|
||||||
|
)
|
||||||
|
addItemRec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(addItemRec, addItemReq)
|
||||||
|
if addItemRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("add item status=%d body=%s", addItemRec.Code, addItemRec.Body.String())
|
||||||
|
}
|
||||||
|
var withItem actionGroupResponse
|
||||||
|
_ = json.Unmarshal(addItemRec.Body.Bytes(), &withItem)
|
||||||
|
itemID := withItem.Group.Items[0].ID
|
||||||
|
|
||||||
|
cloneReq := withSession(
|
||||||
|
httptest.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
"/api/v1/nodes/"+node.ID+"/action-groups/"+createdGroup.Group.ID+"/items/"+itemID+"/clone",
|
||||||
|
nil,
|
||||||
|
),
|
||||||
|
cookie,
|
||||||
|
)
|
||||||
|
cloneRec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(cloneRec, cloneReq)
|
||||||
|
if cloneRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("clone status=%d body=%s", cloneRec.Code, cloneRec.Body.String())
|
||||||
|
}
|
||||||
|
var cloned actionGroupResponse
|
||||||
|
_ = json.Unmarshal(cloneRec.Body.Bytes(), &cloned)
|
||||||
|
clonedItem := cloned.Group.Items[0]
|
||||||
|
if clonedItem.Source != settings.ActionItemSourceLocal || !clonedItem.RequiresSudo {
|
||||||
|
t.Fatalf("cloned item should be local with requires_sudo: %#v", clonedItem)
|
||||||
|
}
|
||||||
|
if clonedItem.Body != "/usr/bin/systemctl restart myservice" {
|
||||||
|
t.Fatalf("cloned body = %q", clonedItem.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,18 +17,20 @@ type actionsResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type createActionRequest struct {
|
type createActionRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Kind settings.ActionKind `json:"kind"`
|
Kind settings.ActionKind `json:"kind"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
Env []settings.ActionEnvVar `json:"env"`
|
Env []settings.ActionEnvVar `json:"env"`
|
||||||
|
RequiresSudo bool `json:"requires_sudo"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type patchActionRequest struct {
|
type patchActionRequest struct {
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
Body *string `json:"body"`
|
Body *string `json:"body"`
|
||||||
Env *[]settings.ActionEnvVar `json:"env"`
|
Env *[]settings.ActionEnvVar `json:"env"`
|
||||||
|
RequiresSudo *bool `json:"requires_sudo"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var envVarNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
var envVarNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||||
@@ -136,6 +138,9 @@ func actionsPatchHandler(configDir string) http.HandlerFunc {
|
|||||||
if payload.Env != nil {
|
if payload.Env != nil {
|
||||||
updated.Env = *payload.Env
|
updated.Env = *payload.Env
|
||||||
}
|
}
|
||||||
|
if payload.RequiresSudo != nil {
|
||||||
|
updated.RequiresSudo = *payload.RequiresSudo
|
||||||
|
}
|
||||||
updated.UpdatedAt = time.Now().UTC()
|
updated.UpdatedAt = time.Now().UTC()
|
||||||
|
|
||||||
if err := validateActionFields(updated.Name, updated.Kind, updated.Body, updated.Env); err != nil {
|
if err := validateActionFields(updated.Name, updated.Kind, updated.Body, updated.Env); err != nil {
|
||||||
@@ -215,15 +220,16 @@ func buildCustomAction(payload createActionRequest) (settings.Action, error) {
|
|||||||
|
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
return settings.Action{
|
return settings.Action{
|
||||||
ID: actionID,
|
ID: actionID,
|
||||||
Name: name,
|
Name: name,
|
||||||
Description: description,
|
Description: description,
|
||||||
Kind: payload.Kind,
|
Kind: payload.Kind,
|
||||||
Body: body,
|
Body: body,
|
||||||
Env: env,
|
Env: env,
|
||||||
Builtin: false,
|
RequiresSudo: payload.RequiresSudo,
|
||||||
CreatedAt: now,
|
Builtin: false,
|
||||||
UpdatedAt: now,
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,8 @@ func TestActionsCreatePatchDelete(t *testing.T) {
|
|||||||
"description": "Print the target IP",
|
"description": "Print the target IP",
|
||||||
"kind": "shell",
|
"kind": "shell",
|
||||||
"body": "echo {{node.ip}}",
|
"body": "echo {{node.ip}}",
|
||||||
"env": [{"name": "APP_HOME", "value": "/opt/app"}]
|
"env": [{"name": "APP_HOME", "value": "/opt/app"}],
|
||||||
|
"requires_sudo": true
|
||||||
}`)
|
}`)
|
||||||
createReq := withSession(httptest.NewRequest(http.MethodPost, "/api/v1/actions", bytes.NewReader(createBody)), cookie)
|
createReq := withSession(httptest.NewRequest(http.MethodPost, "/api/v1/actions", bytes.NewReader(createBody)), cookie)
|
||||||
createRec := httptest.NewRecorder()
|
createRec := httptest.NewRecorder()
|
||||||
@@ -71,8 +72,11 @@ func TestActionsCreatePatchDelete(t *testing.T) {
|
|||||||
if custom.Builtin || custom.Name != "Echo host" || custom.Kind != settings.ActionKindShell {
|
if custom.Builtin || custom.Name != "Echo host" || custom.Kind != settings.ActionKindShell {
|
||||||
t.Fatalf("custom = %#v", custom)
|
t.Fatalf("custom = %#v", custom)
|
||||||
}
|
}
|
||||||
|
if !custom.RequiresSudo {
|
||||||
|
t.Fatalf("expected requires_sudo true, got %#v", custom)
|
||||||
|
}
|
||||||
|
|
||||||
patchBody := []byte(`{"name":"Echo target","body":"echo {{node.host}}"}`)
|
patchBody := []byte(`{"name":"Echo target","body":"echo {{node.host}}","requires_sudo":false}`)
|
||||||
patchReq := withSession(
|
patchReq := withSession(
|
||||||
httptest.NewRequest(http.MethodPatch, "/api/v1/actions?id="+custom.ID, bytes.NewReader(patchBody)),
|
httptest.NewRequest(http.MethodPatch, "/api/v1/actions?id="+custom.ID, bytes.NewReader(patchBody)),
|
||||||
cookie,
|
cookie,
|
||||||
@@ -94,6 +98,9 @@ func TestActionsCreatePatchDelete(t *testing.T) {
|
|||||||
if action.Name != "Echo target" || action.Body != "echo {{node.host}}" {
|
if action.Name != "Echo target" || action.Body != "echo {{node.host}}" {
|
||||||
t.Fatalf("patched = %#v", action)
|
t.Fatalf("patched = %#v", action)
|
||||||
}
|
}
|
||||||
|
if action.RequiresSudo {
|
||||||
|
t.Fatalf("expected requires_sudo false after patch, got %#v", action)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
|
|||||||
@@ -201,6 +201,31 @@ func TestMigrateLogsReadPermission(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMigrateActionsExecutePermission(t *testing.T) {
|
||||||
|
payload := settings.Settings{
|
||||||
|
Groups: []settings.Group{
|
||||||
|
{Name: "runners", Permissions: []string{"jobs.run", "nodes.read"}},
|
||||||
|
{Name: "already", Permissions: []string{"jobs.run", "actions.execute"}},
|
||||||
|
{Name: "viewers", Permissions: []string{"jobs.read"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if !migrateActionsExecutePermission(&payload) {
|
||||||
|
t.Fatal("expected migration to change settings")
|
||||||
|
}
|
||||||
|
if !containsPermission(payload.Groups[0].Permissions, "actions.execute") {
|
||||||
|
t.Fatal("runners should gain actions.execute from jobs.run")
|
||||||
|
}
|
||||||
|
if len(payload.Groups[1].Permissions) != 2 {
|
||||||
|
t.Fatal("already should be unchanged")
|
||||||
|
}
|
||||||
|
if containsPermission(payload.Groups[2].Permissions, "actions.execute") {
|
||||||
|
t.Fatal("viewers should not gain actions.execute")
|
||||||
|
}
|
||||||
|
if migrateActionsExecutePermission(&payload) {
|
||||||
|
t.Fatal("second migration should be a no-op")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func containsPermission(permissions []string, wanted string) bool {
|
func containsPermission(permissions []string, wanted string) bool {
|
||||||
for _, permission := range permissions {
|
for _, permission := range permissions {
|
||||||
if permission == wanted {
|
if permission == wanted {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ var errNodesDeleteRequired = errors.New("permission nodes.delete required")
|
|||||||
var errActionsCreateRequired = errors.New("permission actions.create required")
|
var errActionsCreateRequired = errors.New("permission actions.create required")
|
||||||
var errActionsUpdateRequired = errors.New("permission actions.update required")
|
var errActionsUpdateRequired = errors.New("permission actions.update required")
|
||||||
var errActionsDeleteRequired = errors.New("permission actions.delete required")
|
var errActionsDeleteRequired = errors.New("permission actions.delete required")
|
||||||
|
var errActionsExecuteRequired = errors.New("permission actions.execute required")
|
||||||
var errJobsRunRequired = errors.New("permission jobs.run required")
|
var errJobsRunRequired = errors.New("permission jobs.run required")
|
||||||
var errLogsReadRequired = errors.New("permission logs.read required")
|
var errLogsReadRequired = errors.New("permission logs.read required")
|
||||||
|
|
||||||
@@ -162,6 +163,10 @@ func authorizeActionsDelete(request *http.Request, configDir string) error {
|
|||||||
return authorizeNamedPermission(request, configDir, "actions.delete", errActionsDeleteRequired)
|
return authorizeNamedPermission(request, configDir, "actions.delete", errActionsDeleteRequired)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func authorizeActionsExecute(request *http.Request, configDir string) error {
|
||||||
|
return authorizeNamedPermission(request, configDir, "actions.execute", errActionsExecuteRequired)
|
||||||
|
}
|
||||||
|
|
||||||
func (app *App) authorizeJobsRun(request *http.Request) error {
|
func (app *App) authorizeJobsRun(request *http.Request) error {
|
||||||
user, ok := UserFromContext(request.Context())
|
user, ok := UserFromContext(request.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -22,19 +22,20 @@ type upsertGroupRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var allowedPermissions = map[string]struct{}{
|
var allowedPermissions = map[string]struct{}{
|
||||||
"nodes.read": {},
|
"nodes.read": {},
|
||||||
"nodes.exec": {},
|
"nodes.exec": {},
|
||||||
"nodes.update": {},
|
"nodes.update": {},
|
||||||
"nodes.delete": {},
|
"nodes.delete": {},
|
||||||
"jobs.read": {},
|
"jobs.read": {},
|
||||||
"jobs.run": {},
|
"jobs.run": {},
|
||||||
"actions.create": {},
|
"actions.create": {},
|
||||||
"actions.update": {},
|
"actions.update": {},
|
||||||
"actions.delete": {},
|
"actions.delete": {},
|
||||||
"users.manage": {},
|
"actions.execute": {},
|
||||||
"secrets.manage": {},
|
"users.manage": {},
|
||||||
"roles.manage": {},
|
"secrets.manage": {},
|
||||||
"logs.read": {},
|
"roles.manage": {},
|
||||||
|
"logs.read": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
func groupsGetHandler(configDir string) http.HandlerFunc {
|
func groupsGetHandler(configDir string) http.HandlerFunc {
|
||||||
|
|||||||
@@ -357,6 +357,63 @@ func TestNodesTestSSHReportsFailureForUnreachableHost(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNodesResolveCommandsRejectsInvalidNames(t *testing.T) {
|
||||||
|
configDir := t.TempDir()
|
||||||
|
cookie := seedCompletedSetup(t, configDir)
|
||||||
|
router := NewRouter(configDir)
|
||||||
|
|
||||||
|
createBody := []byte(`{
|
||||||
|
"id":"dddddddd-bbbb-4ccc-8ddd-eeeeeeeeeeee",
|
||||||
|
"kind":"container",
|
||||||
|
"name":"resolve-test",
|
||||||
|
"host_ip":"127.0.0.1",
|
||||||
|
"group_name":"Administrators",
|
||||||
|
"generate":{"algorithm":"ed25519"}
|
||||||
|
}`)
|
||||||
|
createRequest := withSession(
|
||||||
|
httptest.NewRequest(http.MethodPost, "/api/v1/nodes", bytes.NewReader(createBody)),
|
||||||
|
cookie,
|
||||||
|
)
|
||||||
|
createRecorder := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(createRecorder, createRequest)
|
||||||
|
if createRecorder.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create status = %d body=%s", createRecorder.Code, createRecorder.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
badBody := []byte(`{"names":["/usr/bin/apt"]}`)
|
||||||
|
badRequest := withSession(
|
||||||
|
httptest.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
"/api/v1/nodes/dddddddd-bbbb-4ccc-8ddd-eeeeeeeeeeee/resolve-commands",
|
||||||
|
bytes.NewReader(badBody),
|
||||||
|
),
|
||||||
|
cookie,
|
||||||
|
)
|
||||||
|
badRecorder := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(badRecorder, badRequest)
|
||||||
|
if badRecorder.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d body=%s", badRecorder.Code, badRecorder.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(badRecorder.Body.String(), "basename") {
|
||||||
|
t.Fatalf("expected basename error, got %s", badRecorder.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyBody := []byte(`{"names":[]}`)
|
||||||
|
emptyRequest := withSession(
|
||||||
|
httptest.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
"/api/v1/nodes/dddddddd-bbbb-4ccc-8ddd-eeeeeeeeeeee/resolve-commands",
|
||||||
|
bytes.NewReader(emptyBody),
|
||||||
|
),
|
||||||
|
cookie,
|
||||||
|
)
|
||||||
|
emptyRecorder := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(emptyRecorder, emptyRequest)
|
||||||
|
if emptyRecorder.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("empty status = %d body=%s", emptyRecorder.Code, emptyRecorder.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNodesDeleteRequiresPermissionAndInlineReauth(t *testing.T) {
|
func TestNodesDeleteRequiresPermissionAndInlineReauth(t *testing.T) {
|
||||||
configDir := t.TempDir()
|
configDir := t.TempDir()
|
||||||
cookie := seedCompletedSetup(t, configDir)
|
cookie := seedCompletedSetup(t, configDir)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -64,6 +64,7 @@ func NewRouterWithApp(configDir string) (http.Handler, *App) {
|
|||||||
mux.HandleFunc("GET /api/v1/nodes/{id}", app.nodesGetHandler)
|
mux.HandleFunc("GET /api/v1/nodes/{id}", app.nodesGetHandler)
|
||||||
mux.HandleFunc("DELETE /api/v1/nodes/{id}", app.nodesDeleteHandler)
|
mux.HandleFunc("DELETE /api/v1/nodes/{id}", app.nodesDeleteHandler)
|
||||||
mux.HandleFunc("POST /api/v1/nodes/{id}/test-ssh", app.nodesTestSSHHandler)
|
mux.HandleFunc("POST /api/v1/nodes/{id}/test-ssh", app.nodesTestSSHHandler)
|
||||||
|
mux.HandleFunc("POST /api/v1/nodes/{id}/resolve-commands", app.nodesResolveCommandsHandler)
|
||||||
mux.HandleFunc("GET /api/v1/nodes/{id}/action-groups", app.actionGroupsListHandler)
|
mux.HandleFunc("GET /api/v1/nodes/{id}/action-groups", app.actionGroupsListHandler)
|
||||||
mux.HandleFunc("POST /api/v1/nodes/{id}/action-groups", app.actionGroupsCreateHandler)
|
mux.HandleFunc("POST /api/v1/nodes/{id}/action-groups", app.actionGroupsCreateHandler)
|
||||||
mux.HandleFunc("PATCH /api/v1/nodes/{id}/action-groups/{gid}", app.actionGroupsPatchHandler)
|
mux.HandleFunc("PATCH /api/v1/nodes/{id}/action-groups/{gid}", app.actionGroupsPatchHandler)
|
||||||
|
|||||||
@@ -511,6 +511,7 @@ func allPermissionList() []string {
|
|||||||
"actions.create",
|
"actions.create",
|
||||||
"actions.update",
|
"actions.update",
|
||||||
"actions.delete",
|
"actions.delete",
|
||||||
|
"actions.execute",
|
||||||
"users.manage",
|
"users.manage",
|
||||||
"secrets.manage",
|
"secrets.manage",
|
||||||
"roles.manage",
|
"roles.manage",
|
||||||
@@ -540,7 +541,14 @@ func loadSettingsOrDefault(configDir string) (settings.Settings, error) {
|
|||||||
payload.Groups = []settings.Group{}
|
payload.Groups = []settings.Group{}
|
||||||
}
|
}
|
||||||
payload.Network = settings.EffectiveNetwork(payload.Network)
|
payload.Network = settings.EffectiveNetwork(payload.Network)
|
||||||
|
changed := false
|
||||||
if migrateLogsReadPermission(&payload) {
|
if migrateLogsReadPermission(&payload) {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if migrateActionsExecutePermission(&payload) {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
if err := settings.SaveSettings(configDir, payload); err != nil {
|
if err := settings.SaveSettings(configDir, payload); err != nil {
|
||||||
return settings.Settings{}, err
|
return settings.Settings{}, err
|
||||||
}
|
}
|
||||||
@@ -572,3 +580,28 @@ func migrateLogsReadPermission(payload *settings.Settings) bool {
|
|||||||
}
|
}
|
||||||
return changed
|
return changed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// migrateActionsExecutePermission grants actions.execute to groups that
|
||||||
|
// previously could run actions via jobs.run. Returns true when settings changed.
|
||||||
|
func migrateActionsExecutePermission(payload *settings.Settings) bool {
|
||||||
|
changed := false
|
||||||
|
for index := range payload.Groups {
|
||||||
|
group := &payload.Groups[index]
|
||||||
|
hasActionsExecute := false
|
||||||
|
hasJobsRun := false
|
||||||
|
for _, permission := range group.Permissions {
|
||||||
|
switch permission {
|
||||||
|
case "actions.execute":
|
||||||
|
hasActionsExecute = true
|
||||||
|
case "jobs.run":
|
||||||
|
hasJobsRun = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasActionsExecute || !hasJobsRun {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
group.Permissions = append(group.Permissions, "actions.execute")
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const MaxResolveCommandNames = 32
|
||||||
|
|
||||||
|
// ValidateCommandNames checks relative command basenames for resolve-commands.
|
||||||
|
func ValidateCommandNames(names []string) ([]string, error) {
|
||||||
|
if len(names) == 0 {
|
||||||
|
return nil, fmt.Errorf("names is required")
|
||||||
|
}
|
||||||
|
if len(names) > MaxResolveCommandNames {
|
||||||
|
return nil, fmt.Errorf("at most %d names allowed", MaxResolveCommandNames)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleaned := make([]string, 0, len(names))
|
||||||
|
seen := make(map[string]struct{}, len(names))
|
||||||
|
for _, raw := range names {
|
||||||
|
name := strings.TrimSpace(raw)
|
||||||
|
if name == "" {
|
||||||
|
return nil, fmt.Errorf("command name must not be empty")
|
||||||
|
}
|
||||||
|
if strings.Contains(name, "/") {
|
||||||
|
return nil, fmt.Errorf("command name must be a basename without /")
|
||||||
|
}
|
||||||
|
if len(name) > 64 {
|
||||||
|
return nil, fmt.Errorf("command name too long")
|
||||||
|
}
|
||||||
|
for _, runeValue := range name {
|
||||||
|
if (runeValue >= 'a' && runeValue <= 'z') ||
|
||||||
|
(runeValue >= 'A' && runeValue <= 'Z') ||
|
||||||
|
(runeValue >= '0' && runeValue <= '9') ||
|
||||||
|
runeValue == '.' || runeValue == '_' || runeValue == '-' || runeValue == '+' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("command name contains invalid characters")
|
||||||
|
}
|
||||||
|
if _, exists := seen[name]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[name] = struct{}{}
|
||||||
|
cleaned = append(cleaned, name)
|
||||||
|
}
|
||||||
|
if len(cleaned) == 0 {
|
||||||
|
return nil, fmt.Errorf("names is required")
|
||||||
|
}
|
||||||
|
return cleaned, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildResolveCommandsRemote builds a shell snippet that prints name=path lines.
|
||||||
|
func BuildResolveCommandsRemote(names []string) string {
|
||||||
|
var builder strings.Builder
|
||||||
|
for _, name := range names {
|
||||||
|
quoted := shellQuote(name)
|
||||||
|
builder.WriteString("path=$(command -v -- ")
|
||||||
|
builder.WriteString(quoted)
|
||||||
|
builder.WriteString(" 2>/dev/null || true); printf '%s=%s\\n' ")
|
||||||
|
builder.WriteString(quoted)
|
||||||
|
builder.WriteString(" \"$path\"; ")
|
||||||
|
}
|
||||||
|
return builder.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseResolveCommandsOutput parses name=path lines into found paths and missing names.
|
||||||
|
func ParseResolveCommandsOutput(names []string, stdout string) (paths map[string]string, missing []string) {
|
||||||
|
paths = make(map[string]string, len(names))
|
||||||
|
wanted := make(map[string]struct{}, len(names))
|
||||||
|
for _, name := range names {
|
||||||
|
wanted[name] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, line := range strings.Split(stdout, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name, path, ok := strings.Cut(line, "=")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
path = strings.TrimSpace(path)
|
||||||
|
if _, ok := wanted[name]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if path == "" || !strings.HasPrefix(path, "/") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
paths[name] = path
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range names {
|
||||||
|
if _, ok := paths[name]; !ok {
|
||||||
|
missing = append(missing, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paths, missing
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateCommandNames(t *testing.T) {
|
||||||
|
cleaned, err := ValidateCommandNames([]string{" apt ", "systemctl", "apt"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("valid names: %v", err)
|
||||||
|
}
|
||||||
|
if len(cleaned) != 2 || cleaned[0] != "apt" || cleaned[1] != "systemctl" {
|
||||||
|
t.Fatalf("cleaned = %#v", cleaned)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := ValidateCommandNames(nil); err == nil {
|
||||||
|
t.Fatal("expected error for empty names")
|
||||||
|
}
|
||||||
|
if _, err := ValidateCommandNames([]string{"/usr/bin/apt"}); err == nil {
|
||||||
|
t.Fatal("expected error for path")
|
||||||
|
}
|
||||||
|
if _, err := ValidateCommandNames([]string{"apt;rm"}); err == nil {
|
||||||
|
t.Fatal("expected error for metacharacters")
|
||||||
|
}
|
||||||
|
|
||||||
|
tooMany := make([]string, MaxResolveCommandNames+1)
|
||||||
|
for index := range tooMany {
|
||||||
|
tooMany[index] = fmt.Sprintf("cmd%d", index)
|
||||||
|
}
|
||||||
|
if _, err := ValidateCommandNames(tooMany); err == nil {
|
||||||
|
t.Fatal("expected error for too many names")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseResolveCommandsOutput(t *testing.T) {
|
||||||
|
paths, missing := ParseResolveCommandsOutput(
|
||||||
|
[]string{"apt", "missing", "systemctl"},
|
||||||
|
"apt=/usr/bin/apt\nmissing=\nsystemctl=/usr/bin/systemctl\n",
|
||||||
|
)
|
||||||
|
if paths["apt"] != "/usr/bin/apt" || paths["systemctl"] != "/usr/bin/systemctl" {
|
||||||
|
t.Fatalf("paths = %#v", paths)
|
||||||
|
}
|
||||||
|
if len(missing) != 1 || missing[0] != "missing" {
|
||||||
|
t.Fatalf("missing = %#v", missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildResolveCommandsRemote(t *testing.T) {
|
||||||
|
remote := BuildResolveCommandsRemote([]string{"apt"})
|
||||||
|
if !strings.Contains(remote, "command -v -- 'apt'") {
|
||||||
|
t.Fatalf("remote = %q", remote)
|
||||||
|
}
|
||||||
|
if !strings.Contains(remote, "printf '%s=%s\\n' 'apt'") {
|
||||||
|
t.Fatalf("remote missing printf = %q", remote)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,11 +32,12 @@ func RunSSHShell(
|
|||||||
if strings.TrimSpace(command) == "" {
|
if strings.TrimSpace(command) == "" {
|
||||||
return SSHRunResult{}, fmt.Errorf("command is required")
|
return SSHRunResult{}, fmt.Errorf("command is required")
|
||||||
}
|
}
|
||||||
fullCommand := prependEnvExports(env, command)
|
fullCommand := PrependEnvExports(env, command)
|
||||||
return runSSHSession(hostIP, username, privateKeyPEM, passphrase, fullCommand, timeout)
|
return runSSHSession(hostIP, username, privateKeyPEM, passphrase, fullCommand, timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunSSHScript uploads and runs a multi-line script via bash -s on the remote host.
|
// RunSSHScript uploads and runs a multi-line script via bash -s on the remote host.
|
||||||
|
// When requiresSudo is true, the remote command is "sudo -n bash -s".
|
||||||
func RunSSHScript(
|
func RunSSHScript(
|
||||||
hostIP string,
|
hostIP string,
|
||||||
username string,
|
username string,
|
||||||
@@ -45,6 +46,7 @@ func RunSSHScript(
|
|||||||
scriptBody string,
|
scriptBody string,
|
||||||
env map[string]string,
|
env map[string]string,
|
||||||
timeout time.Duration,
|
timeout time.Duration,
|
||||||
|
requiresSudo bool,
|
||||||
) (SSHRunResult, error) {
|
) (SSHRunResult, error) {
|
||||||
if strings.TrimSpace(scriptBody) == "" {
|
if strings.TrimSpace(scriptBody) == "" {
|
||||||
return SSHRunResult{}, fmt.Errorf("script body is required")
|
return SSHRunResult{}, fmt.Errorf("script body is required")
|
||||||
@@ -95,7 +97,11 @@ func RunSSHScript(
|
|||||||
session.Stderr = &stderrBuf
|
session.Stderr = &stderrBuf
|
||||||
session.Stdin = strings.NewReader(scriptBody)
|
session.Stdin = strings.NewReader(scriptBody)
|
||||||
|
|
||||||
remoteCommand := prependEnvExports(env, "bash -s")
|
bashCommand := "bash -s"
|
||||||
|
if requiresSudo {
|
||||||
|
bashCommand = "sudo -n bash -s"
|
||||||
|
}
|
||||||
|
remoteCommand := PrependEnvExports(env, bashCommand)
|
||||||
done := make(chan error, 1)
|
done := make(chan error, 1)
|
||||||
go func() {
|
go func() {
|
||||||
done <- session.Run(remoteCommand)
|
done <- session.Run(remoteCommand)
|
||||||
@@ -207,7 +213,8 @@ func finishSSHResult(stdout string, stderr string, err error) (SSHRunResult, err
|
|||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func prependEnvExports(env map[string]string, command string) string {
|
// PrependEnvExports builds the remote command string with optional export PREFIX; command.
|
||||||
|
func PrependEnvExports(env map[string]string, command string) string {
|
||||||
if len(env) == 0 {
|
if len(env) == 0 {
|
||||||
return command
|
return command
|
||||||
}
|
}
|
||||||
|
|||||||
+233
-2
@@ -1431,7 +1431,6 @@
|
|||||||
.node-action-add-row,
|
.node-action-add-row,
|
||||||
.node-action-group-heading,
|
.node-action-group-heading,
|
||||||
.node-action-group-toolbar,
|
.node-action-group-toolbar,
|
||||||
.node-action-item-heading,
|
|
||||||
.node-action-item-toolbar {
|
.node-action-item-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -1439,6 +1438,78 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.node-action-item-heading {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-action-item-heading > span:first-child {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-action-item-toolbar {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
margin-left: auto;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-action-reorder {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-action-reorder-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.6rem;
|
||||||
|
height: 1.6rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--ctp-surface1);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: var(--ctp-mantle);
|
||||||
|
color: var(--ctp-overlay1);
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-action-reorder-btn:hover:not(:disabled),
|
||||||
|
.node-action-reorder-btn:focus-visible:not(:disabled) {
|
||||||
|
color: var(--ctp-text);
|
||||||
|
background: var(--ctp-surface0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-action-reorder-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-action-group-heading {
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-action-group-heading > input,
|
||||||
|
.node-action-group-heading > strong {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-action-group-toolbar {
|
||||||
|
margin-left: auto;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.node-action-group-list {
|
.node-action-group-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 1rem 0 0;
|
margin: 1rem 0 0;
|
||||||
@@ -1454,6 +1525,145 @@
|
|||||||
background: color-mix(in srgb, var(--ctp-surface0) 70%, transparent);
|
background: color-mix(in srgb, var(--ctp-surface0) 70%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.node-action-group-layout {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-action-group-main {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-action-sudoers {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex: 0 0 22rem;
|
||||||
|
max-width: 26rem;
|
||||||
|
padding: 0.75rem 0.9rem;
|
||||||
|
border: 1px solid var(--ctp-surface1);
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
background: var(--ctp-mantle);
|
||||||
|
position: sticky;
|
||||||
|
top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-action-sudoers:not([open]) {
|
||||||
|
padding: 0.35rem 0.65rem;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-action-sudoers-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-action-sudoers-summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-action-sudoers-summary::marker {
|
||||||
|
content: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-action-sudoers h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-action-sudoers-alert {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
color: var(--ctp-yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-action-sudoers-list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-action-sudoers-resolve {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.resolve-paths-needed {
|
||||||
|
color: var(--ctp-yellow);
|
||||||
|
border-color: var(--ctp-yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyable-code {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.35rem;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0.25rem 0.35rem 0.25rem 0.45rem;
|
||||||
|
border: 1px solid var(--ctp-surface1);
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
background: var(--ctp-crust);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyable-code code {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyable-code-copy {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.15rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ctp-overlay1);
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyable-code-copy:hover,
|
||||||
|
.copyable-code-copy:focus-visible {
|
||||||
|
color: var(--ctp-text);
|
||||||
|
background: var(--ctp-surface0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyable-code-icon {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-action-sudoers-list .copyable-code {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 52rem) {
|
||||||
|
.node-action-group-layout {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-action-sudoers {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
max-width: none;
|
||||||
|
width: 100%;
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.node-action-schedule {
|
.node-action-schedule {
|
||||||
border: 1px solid var(--ctp-surface1);
|
border: 1px solid var(--ctp-surface1);
|
||||||
border-radius: 0.4rem;
|
border-radius: 0.4rem;
|
||||||
@@ -1470,6 +1680,13 @@
|
|||||||
gap: 0.65rem;
|
gap: 0.65rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.node-action-item-editing {
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
border: 1px solid var(--ctp-mauve);
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
background: var(--ctp-mantle);
|
||||||
|
}
|
||||||
|
|
||||||
.node-action-item-edit,
|
.node-action-item-edit,
|
||||||
.node-action-add-custom,
|
.node-action-add-custom,
|
||||||
.node-action-adders {
|
.node-action-adders {
|
||||||
@@ -1478,7 +1695,12 @@
|
|||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-times-editor,
|
.schedule-times-editor {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.65rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.schedule-time-row,
|
.schedule-time-row,
|
||||||
.schedule-weekdays {
|
.schedule-weekdays {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1487,6 +1709,10 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.schedule-time-row {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.checkbox-row {
|
.checkbox-row {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1583,3 +1809,8 @@
|
|||||||
button.danger {
|
button.danger {
|
||||||
color: var(--ctp-red);
|
color: var(--ctp-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button.save-dirty {
|
||||||
|
color: var(--ctp-green);
|
||||||
|
border-color: var(--ctp-green);
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ function stubFetchOk() {
|
|||||||
'actions.create',
|
'actions.create',
|
||||||
'actions.update',
|
'actions.update',
|
||||||
'actions.delete',
|
'actions.delete',
|
||||||
|
'actions.execute',
|
||||||
'users.manage',
|
'users.manage',
|
||||||
'secrets.manage',
|
'secrets.manage',
|
||||||
'roles.manage',
|
'roles.manage',
|
||||||
@@ -93,6 +94,7 @@ function stubFetchOk() {
|
|||||||
'actions.create',
|
'actions.create',
|
||||||
'actions.update',
|
'actions.update',
|
||||||
'actions.delete',
|
'actions.delete',
|
||||||
|
'actions.execute',
|
||||||
'users.manage',
|
'users.manage',
|
||||||
'secrets.manage',
|
'secrets.manage',
|
||||||
'roles.manage',
|
'roles.manage',
|
||||||
@@ -828,6 +830,7 @@ describe('App', () => {
|
|||||||
'actions.create',
|
'actions.create',
|
||||||
'actions.update',
|
'actions.update',
|
||||||
'actions.delete',
|
'actions.delete',
|
||||||
|
'actions.execute',
|
||||||
'users.manage',
|
'users.manage',
|
||||||
'secrets.manage',
|
'secrets.manage',
|
||||||
'roles.manage',
|
'roles.manage',
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ const DEFAULT_PERMISSIONS = [
|
|||||||
'actions.create',
|
'actions.create',
|
||||||
'actions.update',
|
'actions.update',
|
||||||
'actions.delete',
|
'actions.delete',
|
||||||
|
'actions.execute',
|
||||||
'users.manage',
|
'users.manage',
|
||||||
'secrets.manage',
|
'secrets.manage',
|
||||||
'roles.manage',
|
'roles.manage',
|
||||||
@@ -1118,6 +1119,7 @@ function ActionsConfigTab() {
|
|||||||
const [draftDescription, setDraftDescription] = useState('')
|
const [draftDescription, setDraftDescription] = useState('')
|
||||||
const [draftBody, setDraftBody] = useState('')
|
const [draftBody, setDraftBody] = useState('')
|
||||||
const [draftEnv, setDraftEnv] = useState<ActionEnvVar[]>([emptyEnvRow()])
|
const [draftEnv, setDraftEnv] = useState<ActionEnvVar[]>([emptyEnvRow()])
|
||||||
|
const [draftRequiresSudo, setDraftRequiresSudo] = useState(false)
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
|
||||||
const isEditing = editingActionId !== null
|
const isEditing = editingActionId !== null
|
||||||
@@ -1169,6 +1171,7 @@ function ActionsConfigTab() {
|
|||||||
setDraftDescription('')
|
setDraftDescription('')
|
||||||
setDraftBody('')
|
setDraftBody('')
|
||||||
setDraftEnv([emptyEnvRow()])
|
setDraftEnv([emptyEnvRow()])
|
||||||
|
setDraftRequiresSudo(false)
|
||||||
setSubmitError(null)
|
setSubmitError(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1185,6 +1188,7 @@ function ActionsConfigTab() {
|
|||||||
setDraftDescription('')
|
setDraftDescription('')
|
||||||
setDraftBody('')
|
setDraftBody('')
|
||||||
setDraftEnv([emptyEnvRow()])
|
setDraftEnv([emptyEnvRow()])
|
||||||
|
setDraftRequiresSudo(false)
|
||||||
setSubmitError(null)
|
setSubmitError(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1203,6 +1207,7 @@ function ActionsConfigTab() {
|
|||||||
? action.env.map((envVar) => ({ ...envVar }))
|
? action.env.map((envVar) => ({ ...envVar }))
|
||||||
: [emptyEnvRow()],
|
: [emptyEnvRow()],
|
||||||
)
|
)
|
||||||
|
setDraftRequiresSudo(Boolean(action.requires_sudo))
|
||||||
setSubmitError(null)
|
setSubmitError(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1250,6 +1255,7 @@ function ActionsConfigTab() {
|
|||||||
description: draftDescription,
|
description: draftDescription,
|
||||||
body: draftBody,
|
body: draftBody,
|
||||||
env,
|
env,
|
||||||
|
requires_sudo: draftRequiresSudo,
|
||||||
})
|
})
|
||||||
: await createAction({
|
: await createAction({
|
||||||
name: draftName,
|
name: draftName,
|
||||||
@@ -1257,6 +1263,7 @@ function ActionsConfigTab() {
|
|||||||
kind: editorKind,
|
kind: editorKind,
|
||||||
body: draftBody,
|
body: draftBody,
|
||||||
env,
|
env,
|
||||||
|
requires_sudo: draftRequiresSudo,
|
||||||
})
|
})
|
||||||
setActions([...response.actions])
|
setActions([...response.actions])
|
||||||
clearEditor()
|
clearEditor()
|
||||||
@@ -1342,6 +1349,11 @@ function ActionsConfigTab() {
|
|||||||
{action.builtin ? (
|
{action.builtin ? (
|
||||||
<span className="user-badge">built-in</span>
|
<span className="user-badge">built-in</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
{action.requires_sudo ? (
|
||||||
|
<span className="user-badge action-requires-sudo">
|
||||||
|
sudo
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="groups-li-meta">
|
<div className="groups-li-meta">
|
||||||
{action.description || 'No description'}
|
{action.description || 'No description'}
|
||||||
@@ -1521,8 +1533,23 @@ function ActionsConfigTab() {
|
|||||||
onChange={(event) => setDraftBody(event.target.value)}
|
onChange={(event) => setDraftBody(event.target.value)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<p className="config-hint">
|
||||||
|
Do not include a leading <code>sudo</code>; check Requires Sudo
|
||||||
|
instead so ClusterCanvas runs with <code>sudo -n</code>.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label className="checkbox-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={draftRequiresSudo}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDraftRequiresSudo(event.target.checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Requires Sudo
|
||||||
|
</label>
|
||||||
|
|
||||||
<div className="config-field">
|
<div className="config-field">
|
||||||
<label>Environment variables</label>
|
<label>Environment variables</label>
|
||||||
<p className="config-hint">
|
<p className="config-hint">
|
||||||
|
|||||||
+1020
-202
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,188 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import type { Action, NodeActionItem } from './api/client'
|
||||||
|
import {
|
||||||
|
applyResolvedPathToCommand,
|
||||||
|
collectRelativeCommandNames,
|
||||||
|
collectRunsAsPreviews,
|
||||||
|
collectSudoersLines,
|
||||||
|
commandArgv0,
|
||||||
|
runsAsRemoteCommand,
|
||||||
|
stripLeadingSudo,
|
||||||
|
} from './actionSudoers'
|
||||||
|
|
||||||
|
describe('stripLeadingSudo', () => {
|
||||||
|
it('strips a leading sudo', () => {
|
||||||
|
expect(stripLeadingSudo('sudo apt update')).toBe('apt update')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves commands without sudo', () => {
|
||||||
|
expect(stripLeadingSudo('/usr/bin/apt update')).toBe('/usr/bin/apt update')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('commandArgv0 and applyResolvedPathToCommand', () => {
|
||||||
|
it('extracts argv0', () => {
|
||||||
|
expect(commandArgv0('apt update')).toBe('apt')
|
||||||
|
expect(commandArgv0('/usr/bin/apt update')).toBe('/usr/bin/apt')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rewrites relative argv0 from a paths map', () => {
|
||||||
|
expect(
|
||||||
|
applyResolvedPathToCommand('apt update', { apt: '/usr/bin/apt' }),
|
||||||
|
).toBe('/usr/bin/apt update')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves absolute argv0 unchanged', () => {
|
||||||
|
expect(
|
||||||
|
applyResolvedPathToCommand('/usr/bin/apt update', {
|
||||||
|
apt: '/usr/bin/apt',
|
||||||
|
}),
|
||||||
|
).toBe('/usr/bin/apt update')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('collectRelativeCommandNames', () => {
|
||||||
|
it('collects unique relative shell argv0 names', () => {
|
||||||
|
const libraryById = new Map<string, Action>()
|
||||||
|
const items: NodeActionItem[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
source: 'local',
|
||||||
|
name: 'Update',
|
||||||
|
kind: 'shell',
|
||||||
|
body: 'apt update',
|
||||||
|
requires_sudo: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
source: 'local',
|
||||||
|
name: 'Upgrade',
|
||||||
|
kind: 'shell',
|
||||||
|
body: 'apt upgrade -y',
|
||||||
|
requires_sudo: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
source: 'local',
|
||||||
|
name: 'Abs',
|
||||||
|
kind: 'shell',
|
||||||
|
body: '/usr/bin/systemctl restart x',
|
||||||
|
requires_sudo: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
source: 'local',
|
||||||
|
name: 'Script',
|
||||||
|
kind: 'script',
|
||||||
|
body: 'apt update',
|
||||||
|
requires_sudo: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
expect(collectRelativeCommandNames(items, libraryById)).toEqual(['apt'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('runsAsRemoteCommand', () => {
|
||||||
|
it('wraps shell commands with sudo -n', () => {
|
||||||
|
expect(runsAsRemoteCommand('shell', 'apt update', true)).toBe(
|
||||||
|
'sudo -n apt update',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies resolved paths to shell previews', () => {
|
||||||
|
expect(
|
||||||
|
runsAsRemoteCommand('shell', 'apt update', true, {
|
||||||
|
apt: '/usr/bin/apt',
|
||||||
|
}),
|
||||||
|
).toBe('sudo -n /usr/bin/apt update')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses bash -s for scripts with sudo', () => {
|
||||||
|
expect(
|
||||||
|
runsAsRemoteCommand('script', 'apt update\napt upgrade -y', true),
|
||||||
|
).toBe('sudo -n bash -s')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('collectSudoersLines', () => {
|
||||||
|
const libraryById = new Map<string, Action>()
|
||||||
|
|
||||||
|
it('suggests the shell body for shell actions', () => {
|
||||||
|
const items: NodeActionItem[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
source: 'local',
|
||||||
|
name: 'Update',
|
||||||
|
kind: 'shell',
|
||||||
|
body: 'apt update',
|
||||||
|
requires_sudo: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
expect(collectSudoersLines(items, libraryById, 'clustercanvas')).toEqual([
|
||||||
|
'clustercanvas ALL=(root) NOPASSWD: apt update',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies resolved paths to sudoers suggestions', () => {
|
||||||
|
const items: NodeActionItem[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
source: 'local',
|
||||||
|
name: 'Update',
|
||||||
|
kind: 'shell',
|
||||||
|
body: 'apt update',
|
||||||
|
requires_sudo: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
expect(
|
||||||
|
collectSudoersLines(items, libraryById, 'clustercanvas', {
|
||||||
|
apt: '/usr/bin/apt',
|
||||||
|
}),
|
||||||
|
).toEqual(['clustercanvas ALL=(root) NOPASSWD: /usr/bin/apt update'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('suggests bash -s for script actions, not script lines', () => {
|
||||||
|
const items: NodeActionItem[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
source: 'local',
|
||||||
|
name: 'Update',
|
||||||
|
kind: 'script',
|
||||||
|
body: 'apt update\napt upgrade -y',
|
||||||
|
requires_sudo: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
expect(collectSudoersLines(items, libraryById, 'clustercanvas')).toEqual([
|
||||||
|
'clustercanvas ALL=(root) NOPASSWD: /usr/bin/bash -s',
|
||||||
|
'clustercanvas ALL=(root) NOPASSWD: /bin/bash -s',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('collectRunsAsPreviews', () => {
|
||||||
|
it('lists unique remote commands for sudo items', () => {
|
||||||
|
const libraryById = new Map<string, Action>()
|
||||||
|
const items: NodeActionItem[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
source: 'local',
|
||||||
|
name: 'Update',
|
||||||
|
kind: 'shell',
|
||||||
|
body: 'apt update',
|
||||||
|
requires_sudo: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
source: 'local',
|
||||||
|
name: 'Scripted',
|
||||||
|
kind: 'script',
|
||||||
|
body: 'apt update',
|
||||||
|
requires_sudo: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
expect(collectRunsAsPreviews(items, libraryById)).toEqual([
|
||||||
|
'sudo -n apt update',
|
||||||
|
'sudo -n bash -s',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import type { Action, NodeActionItem } from './api/client'
|
||||||
|
|
||||||
|
export function itemRequiresSudo(
|
||||||
|
item: NodeActionItem,
|
||||||
|
libraryById: Map<string, Action>,
|
||||||
|
): boolean {
|
||||||
|
if (item.source === 'local') {
|
||||||
|
return Boolean(item.requires_sudo)
|
||||||
|
}
|
||||||
|
const libraryAction = libraryById.get(item.library_action_id || '')
|
||||||
|
return Boolean(libraryAction?.requires_sudo)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function itemResolvedBody(
|
||||||
|
item: NodeActionItem,
|
||||||
|
libraryById: Map<string, Action>,
|
||||||
|
): { kind: 'shell' | 'script'; body: string } | null {
|
||||||
|
if (item.source === 'local') {
|
||||||
|
if (!item.body?.trim()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: item.kind === 'script' ? 'script' : 'shell',
|
||||||
|
body: item.body,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const libraryAction = libraryById.get(item.library_action_id || '')
|
||||||
|
if (!libraryAction) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return { kind: libraryAction.kind, body: libraryAction.body }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripLeadingSudo(command: string): string {
|
||||||
|
const trimmed = command.trim()
|
||||||
|
if (/^sudo(\s|$)/i.test(trimmed)) {
|
||||||
|
return trimmed.replace(/^sudo\s+/i, '').trim()
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First whitespace-separated token of a shell command body. */
|
||||||
|
export function commandArgv0(command: string): string {
|
||||||
|
const trimmed = stripLeadingSudo(command)
|
||||||
|
if (!trimmed) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const match = trimmed.match(/^[^\s]+/)
|
||||||
|
return match ? match[0] : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replace argv0 when paths has a mapping for that relative name. */
|
||||||
|
export function applyResolvedPathToCommand(
|
||||||
|
command: string,
|
||||||
|
paths: Readonly<Record<string, string>>,
|
||||||
|
): string {
|
||||||
|
const trimmed = command.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
const argv0 = commandArgv0(trimmed)
|
||||||
|
if (!argv0 || argv0.startsWith('/')) {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
const absolute = paths[argv0]
|
||||||
|
if (!absolute) {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
return `${absolute}${trimmed.slice(argv0.length)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Relative argv0 names from shell sudo items (for resolve-commands). */
|
||||||
|
export function collectRelativeCommandNames(
|
||||||
|
items: ReadonlyArray<NodeActionItem>,
|
||||||
|
libraryById: Map<string, Action>,
|
||||||
|
): string[] {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const names: string[] = []
|
||||||
|
for (const item of items) {
|
||||||
|
if (!itemRequiresSudo(item, libraryById)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const resolved = itemResolvedBody(item, libraryById)
|
||||||
|
if (!resolved || resolved.kind !== 'shell') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const argv0 = commandArgv0(resolved.body)
|
||||||
|
if (!argv0 || argv0.startsWith('/') || seen.has(argv0)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen.add(argv0)
|
||||||
|
names.push(argv0)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remote command ClusterCanvas runs (env exports omitted). */
|
||||||
|
export function runsAsRemoteCommand(
|
||||||
|
kind: 'shell' | 'script',
|
||||||
|
body: string,
|
||||||
|
requiresSudo: boolean,
|
||||||
|
paths?: Readonly<Record<string, string>>,
|
||||||
|
): string {
|
||||||
|
if (kind === 'script') {
|
||||||
|
return requiresSudo ? 'sudo -n bash -s' : 'bash -s'
|
||||||
|
}
|
||||||
|
let command = stripLeadingSudo(body)
|
||||||
|
if (paths) {
|
||||||
|
command = applyResolvedPathToCommand(command, paths)
|
||||||
|
}
|
||||||
|
if (!command) {
|
||||||
|
return requiresSudo ? 'sudo -n' : ''
|
||||||
|
}
|
||||||
|
return requiresSudo ? `sudo -n ${command}` : command
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCRIPT_SUDO_COMMANDS = ['/usr/bin/bash -s', '/bin/bash -s'] as const
|
||||||
|
|
||||||
|
function exactSudoersCommands(
|
||||||
|
kind: 'shell' | 'script',
|
||||||
|
body: string,
|
||||||
|
paths?: Readonly<Record<string, string>>,
|
||||||
|
): string[] {
|
||||||
|
if (kind === 'script') {
|
||||||
|
return [...SCRIPT_SUDO_COMMANDS]
|
||||||
|
}
|
||||||
|
let command = stripLeadingSudo(body)
|
||||||
|
if (paths) {
|
||||||
|
command = applyResolvedPathToCommand(command, paths)
|
||||||
|
}
|
||||||
|
return command ? [command] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectSudoersLines(
|
||||||
|
items: ReadonlyArray<NodeActionItem>,
|
||||||
|
libraryById: Map<string, Action>,
|
||||||
|
username: string,
|
||||||
|
paths?: Readonly<Record<string, string>>,
|
||||||
|
): string[] {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const lines: string[] = []
|
||||||
|
for (const item of items) {
|
||||||
|
if (!itemRequiresSudo(item, libraryById)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const resolved = itemResolvedBody(item, libraryById)
|
||||||
|
if (!resolved) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for (const command of exactSudoersCommands(
|
||||||
|
resolved.kind,
|
||||||
|
resolved.body,
|
||||||
|
paths,
|
||||||
|
)) {
|
||||||
|
const line = `${username} ALL=(root) NOPASSWD: ${command}`
|
||||||
|
if (seen.has(line)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen.add(line)
|
||||||
|
lines.push(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectRunsAsPreviews(
|
||||||
|
items: ReadonlyArray<NodeActionItem>,
|
||||||
|
libraryById: Map<string, Action>,
|
||||||
|
paths?: Readonly<Record<string, string>>,
|
||||||
|
): string[] {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const previews: string[] = []
|
||||||
|
for (const item of items) {
|
||||||
|
if (!itemRequiresSudo(item, libraryById)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const resolved = itemResolvedBody(item, libraryById)
|
||||||
|
if (!resolved) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const preview = runsAsRemoteCommand(
|
||||||
|
resolved.kind,
|
||||||
|
resolved.body,
|
||||||
|
true,
|
||||||
|
paths,
|
||||||
|
)
|
||||||
|
if (!preview || seen.has(preview)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen.add(preview)
|
||||||
|
previews.push(preview)
|
||||||
|
}
|
||||||
|
return previews
|
||||||
|
}
|
||||||
@@ -774,6 +774,31 @@ export async function testNodeSSH(
|
|||||||
return (await response.json()) as NodeSSHTestResponse
|
return (await response.json()) as NodeSSHTestResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ResolveCommandsResponse = {
|
||||||
|
paths: Record<string, string>
|
||||||
|
missing: ReadonlyArray<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveNodeCommands(
|
||||||
|
id: string,
|
||||||
|
names: ReadonlyArray<string>,
|
||||||
|
fetchImpl: typeof fetch = fetch,
|
||||||
|
): Promise<ResolveCommandsResponse> {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`/api/v1/nodes/${encodeURIComponent(id)}/resolve-commands`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ names }),
|
||||||
|
},
|
||||||
|
fetchImpl,
|
||||||
|
)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readErrorMessage(response))
|
||||||
|
}
|
||||||
|
return (await response.json()) as ResolveCommandsResponse
|
||||||
|
}
|
||||||
|
|
||||||
export type NodeAuditAction = 'create' | 'delete' | 'test_ssh' | 'update'
|
export type NodeAuditAction = 'create' | 'delete' | 'test_ssh' | 'update'
|
||||||
|
|
||||||
export type NodeAuditEvent = {
|
export type NodeAuditEvent = {
|
||||||
@@ -847,6 +872,7 @@ export type Action = {
|
|||||||
kind: ActionKind
|
kind: ActionKind
|
||||||
body: string
|
body: string
|
||||||
env: ReadonlyArray<ActionEnvVar>
|
env: ReadonlyArray<ActionEnvVar>
|
||||||
|
requires_sudo: boolean
|
||||||
builtin: boolean
|
builtin: boolean
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
@@ -862,6 +888,7 @@ export type CreateActionRequest = {
|
|||||||
kind: ActionKind
|
kind: ActionKind
|
||||||
body: string
|
body: string
|
||||||
env: ReadonlyArray<ActionEnvVar>
|
env: ReadonlyArray<ActionEnvVar>
|
||||||
|
requires_sudo?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PatchActionRequest = {
|
export type PatchActionRequest = {
|
||||||
@@ -869,6 +896,7 @@ export type PatchActionRequest = {
|
|||||||
description?: string
|
description?: string
|
||||||
body?: string
|
body?: string
|
||||||
env?: ReadonlyArray<ActionEnvVar>
|
env?: ReadonlyArray<ActionEnvVar>
|
||||||
|
requires_sudo?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchActions(
|
export async function fetchActions(
|
||||||
@@ -958,6 +986,9 @@ export type NodeActionItem = {
|
|||||||
kind?: ActionKind
|
kind?: ActionKind
|
||||||
body?: string
|
body?: string
|
||||||
env?: ReadonlyArray<ActionEnvVar>
|
env?: ReadonlyArray<ActionEnvVar>
|
||||||
|
requires_sudo?: boolean
|
||||||
|
set_variable?: string
|
||||||
|
run_if?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type NodeActionGroup = {
|
export type NodeActionGroup = {
|
||||||
@@ -983,10 +1014,17 @@ export type ActionItemRunResult = {
|
|||||||
item_id: string
|
item_id: string
|
||||||
action_name: string
|
action_name: string
|
||||||
source: string
|
source: string
|
||||||
|
kind?: ActionKind
|
||||||
|
remote_command?: string
|
||||||
|
ssh_username?: string
|
||||||
exit_code: number
|
exit_code: number
|
||||||
stdout: string
|
stdout: string
|
||||||
stderr: string
|
stderr: string
|
||||||
error?: string
|
error?: string
|
||||||
|
skipped?: boolean
|
||||||
|
skip_reason?: string
|
||||||
|
set_variable?: string
|
||||||
|
variable_value?: string
|
||||||
started_at: string
|
started_at: string
|
||||||
finished_at: string
|
finished_at: string
|
||||||
}
|
}
|
||||||
@@ -1048,6 +1086,9 @@ export type CreateActionGroupItemRequest = {
|
|||||||
kind?: ActionKind
|
kind?: ActionKind
|
||||||
body?: string
|
body?: string
|
||||||
env?: ReadonlyArray<ActionEnvVar>
|
env?: ReadonlyArray<ActionEnvVar>
|
||||||
|
requires_sudo?: boolean
|
||||||
|
set_variable?: string
|
||||||
|
run_if?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PatchActionGroupItemRequest = {
|
export type PatchActionGroupItemRequest = {
|
||||||
@@ -1056,6 +1097,9 @@ export type PatchActionGroupItemRequest = {
|
|||||||
kind?: ActionKind
|
kind?: ActionKind
|
||||||
body?: string
|
body?: string
|
||||||
env?: ReadonlyArray<ActionEnvVar>
|
env?: ReadonlyArray<ActionEnvVar>
|
||||||
|
requires_sudo?: boolean
|
||||||
|
set_variable?: string
|
||||||
|
run_if?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchActionGroups(
|
export async function fetchActionGroups(
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
--ctp-mauve: #8839ef;
|
--ctp-mauve: #8839ef;
|
||||||
--ctp-red: #d20f39;
|
--ctp-red: #d20f39;
|
||||||
--ctp-green: #40a02b;
|
--ctp-green: #40a02b;
|
||||||
|
--ctp-yellow: #df8e1d;
|
||||||
--ctp-blue: #1e66f5;
|
--ctp-blue: #1e66f5;
|
||||||
--ctp-mauve-muted: color-mix(in srgb, var(--ctp-mauve) 18%, var(--ctp-base));
|
--ctp-mauve-muted: color-mix(in srgb, var(--ctp-mauve) 18%, var(--ctp-base));
|
||||||
--ctp-mauve-hover: color-mix(in srgb, var(--ctp-mauve) 12%, var(--ctp-base));
|
--ctp-mauve-hover: color-mix(in srgb, var(--ctp-mauve) 12%, var(--ctp-base));
|
||||||
@@ -35,6 +36,7 @@
|
|||||||
--ctp-mauve: #cba6f7;
|
--ctp-mauve: #cba6f7;
|
||||||
--ctp-red: #f38ba8;
|
--ctp-red: #f38ba8;
|
||||||
--ctp-green: #a6e3a1;
|
--ctp-green: #a6e3a1;
|
||||||
|
--ctp-yellow: #f9e2af;
|
||||||
--ctp-blue: #89b4fa;
|
--ctp-blue: #89b4fa;
|
||||||
--ctp-mauve-muted: color-mix(in srgb, var(--ctp-mauve) 22%, var(--ctp-base));
|
--ctp-mauve-muted: color-mix(in srgb, var(--ctp-mauve) 22%, var(--ctp-base));
|
||||||
--ctp-mauve-hover: color-mix(in srgb, var(--ctp-mauve) 14%, var(--ctp-base));
|
--ctp-mauve-hover: color-mix(in srgb, var(--ctp-mauve) 14%, var(--ctp-base));
|
||||||
|
|||||||
Reference in New Issue
Block a user