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"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/runner"
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||
)
|
||||
|
||||
@@ -50,6 +51,9 @@ type createActionGroupItemRequest struct {
|
||||
Kind settings.ActionKind `json:"kind"`
|
||||
Body string `json:"body"`
|
||||
Env []settings.ActionEnvVar `json:"env"`
|
||||
RequiresSudo bool `json:"requires_sudo"`
|
||||
SetVariable string `json:"set_variable"`
|
||||
RunIf string `json:"run_if"`
|
||||
}
|
||||
|
||||
type patchActionGroupItemRequest struct {
|
||||
@@ -58,6 +62,9 @@ type patchActionGroupItemRequest struct {
|
||||
Kind *settings.ActionKind `json:"kind"`
|
||||
Body *string `json:"body"`
|
||||
Env *[]settings.ActionEnvVar `json:"env"`
|
||||
RequiresSudo *bool `json:"requires_sudo"`
|
||||
SetVariable *string `json:"set_variable"`
|
||||
RunIf *string `json:"run_if"`
|
||||
}
|
||||
|
||||
type reorderActionGroupItemsRequest struct {
|
||||
@@ -325,11 +332,13 @@ func (app *App) actionGroupItemsPatchHandler(writer http.ResponseWriter, request
|
||||
return
|
||||
}
|
||||
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"})
|
||||
return
|
||||
}
|
||||
|
||||
if item.Source == settings.ActionItemSourceLocal {
|
||||
if payload.Name != nil {
|
||||
item.Name = strings.TrimSpace(*payload.Name)
|
||||
}
|
||||
@@ -345,6 +354,9 @@ func (app *App) actionGroupItemsPatchHandler(writer http.ResponseWriter, request
|
||||
if payload.Env != nil {
|
||||
item.Env = *payload.Env
|
||||
}
|
||||
if payload.RequiresSudo != nil {
|
||||
item.RequiresSudo = *payload.RequiresSudo
|
||||
}
|
||||
if item.Env == nil {
|
||||
item.Env = []settings.ActionEnvVar{}
|
||||
}
|
||||
@@ -352,6 +364,24 @@ func (app *App) actionGroupItemsPatchHandler(writer http.ResponseWriter, request
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
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.RunIf != nil {
|
||||
runIf, err := normalizeRunIf(*payload.RunIf)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
item.RunIf = runIf
|
||||
}
|
||||
|
||||
store.Groups[groupIndex].Items[itemIndex] = item
|
||||
store.Groups[groupIndex].UpdatedAt = time.Now().UTC()
|
||||
@@ -425,6 +455,7 @@ func (app *App) actionGroupItemsCloneHandler(writer http.ResponseWriter, request
|
||||
item.Kind = libraryAction.Kind
|
||||
item.Body = libraryAction.Body
|
||||
item.Env = env
|
||||
item.RequiresSudo = libraryAction.RequiresSudo
|
||||
|
||||
store.Groups[groupIndex].Items[itemIndex] = item
|
||||
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) {
|
||||
if err := app.authorizeJobsRun(request); err != nil {
|
||||
if err := authorizeActionsExecute(request, app.ConfigDir); err != nil {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -565,7 +596,7 @@ func (app *App) actionGroupsRunHandler(writer http.ResponseWriter, request *http
|
||||
}
|
||||
|
||||
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()})
|
||||
return
|
||||
}
|
||||
@@ -700,6 +731,14 @@ func buildNodeActionItem(payload createActionGroupItemRequest) (settings.NodeAct
|
||||
if err != nil {
|
||||
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 {
|
||||
case settings.ActionItemSourceLibrary:
|
||||
libraryID := strings.TrimSpace(payload.LibraryActionID)
|
||||
@@ -711,6 +750,8 @@ func buildNodeActionItem(payload createActionGroupItemRequest) (settings.NodeAct
|
||||
Source: settings.ActionItemSourceLibrary,
|
||||
LibraryActionID: libraryID,
|
||||
Env: []settings.ActionEnvVar{},
|
||||
SetVariable: setVariable,
|
||||
RunIf: runIf,
|
||||
}, nil
|
||||
case settings.ActionItemSourceLocal:
|
||||
env := payload.Env
|
||||
@@ -730,12 +771,41 @@ func buildNodeActionItem(payload createActionGroupItemRequest) (settings.NodeAct
|
||||
Kind: payload.Kind,
|
||||
Body: body,
|
||||
Env: env,
|
||||
RequiresSudo: payload.RequiresSudo,
|
||||
SetVariable: setVariable,
|
||||
RunIf: runIf,
|
||||
}, nil
|
||||
default:
|
||||
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 {
|
||||
switch schedule.Kind {
|
||||
case settings.ActionGroupScheduleInterval:
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -168,6 +169,9 @@ func TestActionGroupsCRUDCloneReorderAndAuthz(t *testing.T) {
|
||||
if clonedItem.Source != settings.ActionItemSourceLocal || clonedItem.Name != "Uptime" {
|
||||
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.
|
||||
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) {
|
||||
configDir := t.TempDir()
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ type createActionRequest struct {
|
||||
Kind settings.ActionKind `json:"kind"`
|
||||
Body string `json:"body"`
|
||||
Env []settings.ActionEnvVar `json:"env"`
|
||||
RequiresSudo bool `json:"requires_sudo"`
|
||||
}
|
||||
|
||||
type patchActionRequest struct {
|
||||
@@ -29,6 +30,7 @@ type patchActionRequest struct {
|
||||
Description *string `json:"description"`
|
||||
Body *string `json:"body"`
|
||||
Env *[]settings.ActionEnvVar `json:"env"`
|
||||
RequiresSudo *bool `json:"requires_sudo"`
|
||||
}
|
||||
|
||||
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 {
|
||||
updated.Env = *payload.Env
|
||||
}
|
||||
if payload.RequiresSudo != nil {
|
||||
updated.RequiresSudo = *payload.RequiresSudo
|
||||
}
|
||||
updated.UpdatedAt = time.Now().UTC()
|
||||
|
||||
if err := validateActionFields(updated.Name, updated.Kind, updated.Body, updated.Env); err != nil {
|
||||
@@ -221,6 +226,7 @@ func buildCustomAction(payload createActionRequest) (settings.Action, error) {
|
||||
Kind: payload.Kind,
|
||||
Body: body,
|
||||
Env: env,
|
||||
RequiresSudo: payload.RequiresSudo,
|
||||
Builtin: false,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
|
||||
@@ -51,7 +51,8 @@ func TestActionsCreatePatchDelete(t *testing.T) {
|
||||
"description": "Print the target IP",
|
||||
"kind": "shell",
|
||||
"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)
|
||||
createRec := httptest.NewRecorder()
|
||||
@@ -71,8 +72,11 @@ func TestActionsCreatePatchDelete(t *testing.T) {
|
||||
if custom.Builtin || custom.Name != "Echo host" || custom.Kind != settings.ActionKindShell {
|
||||
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(
|
||||
httptest.NewRequest(http.MethodPatch, "/api/v1/actions?id="+custom.ID, bytes.NewReader(patchBody)),
|
||||
cookie,
|
||||
@@ -94,6 +98,9 @@ func TestActionsCreatePatchDelete(t *testing.T) {
|
||||
if action.Name != "Echo target" || action.Body != "echo {{node.host}}" {
|
||||
t.Fatalf("patched = %#v", action)
|
||||
}
|
||||
if action.RequiresSudo {
|
||||
t.Fatalf("expected requires_sudo false after patch, got %#v", action)
|
||||
}
|
||||
}
|
||||
}
|
||||
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 {
|
||||
for _, permission := range permissions {
|
||||
if permission == wanted {
|
||||
|
||||
@@ -14,6 +14,7 @@ var errNodesDeleteRequired = errors.New("permission nodes.delete required")
|
||||
var errActionsCreateRequired = errors.New("permission actions.create required")
|
||||
var errActionsUpdateRequired = errors.New("permission actions.update 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 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)
|
||||
}
|
||||
|
||||
func authorizeActionsExecute(request *http.Request, configDir string) error {
|
||||
return authorizeNamedPermission(request, configDir, "actions.execute", errActionsExecuteRequired)
|
||||
}
|
||||
|
||||
func (app *App) authorizeJobsRun(request *http.Request) error {
|
||||
user, ok := UserFromContext(request.Context())
|
||||
if !ok {
|
||||
|
||||
@@ -31,6 +31,7 @@ var allowedPermissions = map[string]struct{}{
|
||||
"actions.create": {},
|
||||
"actions.update": {},
|
||||
"actions.delete": {},
|
||||
"actions.execute": {},
|
||||
"users.manage": {},
|
||||
"secrets.manage": {},
|
||||
"roles.manage": {},
|
||||
|
||||
@@ -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) {
|
||||
configDir := t.TempDir()
|
||||
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("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}/resolve-commands", app.nodesResolveCommandsHandler)
|
||||
mux.HandleFunc("GET /api/v1/nodes/{id}/action-groups", app.actionGroupsListHandler)
|
||||
mux.HandleFunc("POST /api/v1/nodes/{id}/action-groups", app.actionGroupsCreateHandler)
|
||||
mux.HandleFunc("PATCH /api/v1/nodes/{id}/action-groups/{gid}", app.actionGroupsPatchHandler)
|
||||
|
||||
@@ -511,6 +511,7 @@ func allPermissionList() []string {
|
||||
"actions.create",
|
||||
"actions.update",
|
||||
"actions.delete",
|
||||
"actions.execute",
|
||||
"users.manage",
|
||||
"secrets.manage",
|
||||
"roles.manage",
|
||||
@@ -540,7 +541,14 @@ func loadSettingsOrDefault(configDir string) (settings.Settings, error) {
|
||||
payload.Groups = []settings.Group{}
|
||||
}
|
||||
payload.Network = settings.EffectiveNetwork(payload.Network)
|
||||
changed := false
|
||||
if migrateLogsReadPermission(&payload) {
|
||||
changed = true
|
||||
}
|
||||
if migrateActionsExecutePermission(&payload) {
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
if err := settings.SaveSettings(configDir, payload); err != nil {
|
||||
return settings.Settings{}, err
|
||||
}
|
||||
@@ -572,3 +580,28 @@ func migrateLogsReadPermission(payload *settings.Settings) bool {
|
||||
}
|
||||
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) == "" {
|
||||
return SSHRunResult{}, fmt.Errorf("command is required")
|
||||
}
|
||||
fullCommand := prependEnvExports(env, command)
|
||||
fullCommand := PrependEnvExports(env, command)
|
||||
return runSSHSession(hostIP, username, privateKeyPEM, passphrase, fullCommand, timeout)
|
||||
}
|
||||
|
||||
// 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(
|
||||
hostIP string,
|
||||
username string,
|
||||
@@ -45,6 +46,7 @@ func RunSSHScript(
|
||||
scriptBody string,
|
||||
env map[string]string,
|
||||
timeout time.Duration,
|
||||
requiresSudo bool,
|
||||
) (SSHRunResult, error) {
|
||||
if strings.TrimSpace(scriptBody) == "" {
|
||||
return SSHRunResult{}, fmt.Errorf("script body is required")
|
||||
@@ -95,7 +97,11 @@ func RunSSHScript(
|
||||
session.Stderr = &stderrBuf
|
||||
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)
|
||||
go func() {
|
||||
done <- session.Run(remoteCommand)
|
||||
@@ -207,7 +213,8 @@ func finishSSHResult(stdout string, stderr string, err error) (SSHRunResult, 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 {
|
||||
return command
|
||||
}
|
||||
|
||||
+233
-2
@@ -1431,7 +1431,6 @@
|
||||
.node-action-add-row,
|
||||
.node-action-group-heading,
|
||||
.node-action-group-toolbar,
|
||||
.node-action-item-heading,
|
||||
.node-action-item-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -1439,6 +1438,78 @@
|
||||
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 {
|
||||
list-style: none;
|
||||
margin: 1rem 0 0;
|
||||
@@ -1454,6 +1525,145 @@
|
||||
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 {
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.4rem;
|
||||
@@ -1470,6 +1680,13 @@
|
||||
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-add-custom,
|
||||
.node-action-adders {
|
||||
@@ -1478,7 +1695,12 @@
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.schedule-times-editor,
|
||||
.schedule-times-editor {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.schedule-time-row,
|
||||
.schedule-weekdays {
|
||||
display: flex;
|
||||
@@ -1487,6 +1709,10 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.schedule-time-row {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.checkbox-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1583,3 +1809,8 @@
|
||||
button.danger {
|
||||
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.update',
|
||||
'actions.delete',
|
||||
'actions.execute',
|
||||
'users.manage',
|
||||
'secrets.manage',
|
||||
'roles.manage',
|
||||
@@ -93,6 +94,7 @@ function stubFetchOk() {
|
||||
'actions.create',
|
||||
'actions.update',
|
||||
'actions.delete',
|
||||
'actions.execute',
|
||||
'users.manage',
|
||||
'secrets.manage',
|
||||
'roles.manage',
|
||||
@@ -828,6 +830,7 @@ describe('App', () => {
|
||||
'actions.create',
|
||||
'actions.update',
|
||||
'actions.delete',
|
||||
'actions.execute',
|
||||
'users.manage',
|
||||
'secrets.manage',
|
||||
'roles.manage',
|
||||
|
||||
@@ -149,6 +149,7 @@ const DEFAULT_PERMISSIONS = [
|
||||
'actions.create',
|
||||
'actions.update',
|
||||
'actions.delete',
|
||||
'actions.execute',
|
||||
'users.manage',
|
||||
'secrets.manage',
|
||||
'roles.manage',
|
||||
@@ -1118,6 +1119,7 @@ function ActionsConfigTab() {
|
||||
const [draftDescription, setDraftDescription] = useState('')
|
||||
const [draftBody, setDraftBody] = useState('')
|
||||
const [draftEnv, setDraftEnv] = useState<ActionEnvVar[]>([emptyEnvRow()])
|
||||
const [draftRequiresSudo, setDraftRequiresSudo] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
const isEditing = editingActionId !== null
|
||||
@@ -1169,6 +1171,7 @@ function ActionsConfigTab() {
|
||||
setDraftDescription('')
|
||||
setDraftBody('')
|
||||
setDraftEnv([emptyEnvRow()])
|
||||
setDraftRequiresSudo(false)
|
||||
setSubmitError(null)
|
||||
}
|
||||
|
||||
@@ -1185,6 +1188,7 @@ function ActionsConfigTab() {
|
||||
setDraftDescription('')
|
||||
setDraftBody('')
|
||||
setDraftEnv([emptyEnvRow()])
|
||||
setDraftRequiresSudo(false)
|
||||
setSubmitError(null)
|
||||
}
|
||||
|
||||
@@ -1203,6 +1207,7 @@ function ActionsConfigTab() {
|
||||
? action.env.map((envVar) => ({ ...envVar }))
|
||||
: [emptyEnvRow()],
|
||||
)
|
||||
setDraftRequiresSudo(Boolean(action.requires_sudo))
|
||||
setSubmitError(null)
|
||||
}
|
||||
|
||||
@@ -1250,6 +1255,7 @@ function ActionsConfigTab() {
|
||||
description: draftDescription,
|
||||
body: draftBody,
|
||||
env,
|
||||
requires_sudo: draftRequiresSudo,
|
||||
})
|
||||
: await createAction({
|
||||
name: draftName,
|
||||
@@ -1257,6 +1263,7 @@ function ActionsConfigTab() {
|
||||
kind: editorKind,
|
||||
body: draftBody,
|
||||
env,
|
||||
requires_sudo: draftRequiresSudo,
|
||||
})
|
||||
setActions([...response.actions])
|
||||
clearEditor()
|
||||
@@ -1342,6 +1349,11 @@ function ActionsConfigTab() {
|
||||
{action.builtin ? (
|
||||
<span className="user-badge">built-in</span>
|
||||
) : null}
|
||||
{action.requires_sudo ? (
|
||||
<span className="user-badge action-requires-sudo">
|
||||
sudo
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="groups-li-meta">
|
||||
{action.description || 'No description'}
|
||||
@@ -1521,8 +1533,23 @@ function ActionsConfigTab() {
|
||||
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>
|
||||
|
||||
<label className="checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draftRequiresSudo}
|
||||
onChange={(event) =>
|
||||
setDraftRequiresSudo(event.target.checked)
|
||||
}
|
||||
/>
|
||||
Requires Sudo
|
||||
</label>
|
||||
|
||||
<div className="config-field">
|
||||
<label>Environment variables</label>
|
||||
<p className="config-hint">
|
||||
|
||||
+977
-159
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
|
||||
}
|
||||
|
||||
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 NodeAuditEvent = {
|
||||
@@ -847,6 +872,7 @@ export type Action = {
|
||||
kind: ActionKind
|
||||
body: string
|
||||
env: ReadonlyArray<ActionEnvVar>
|
||||
requires_sudo: boolean
|
||||
builtin: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
@@ -862,6 +888,7 @@ export type CreateActionRequest = {
|
||||
kind: ActionKind
|
||||
body: string
|
||||
env: ReadonlyArray<ActionEnvVar>
|
||||
requires_sudo?: boolean
|
||||
}
|
||||
|
||||
export type PatchActionRequest = {
|
||||
@@ -869,6 +896,7 @@ export type PatchActionRequest = {
|
||||
description?: string
|
||||
body?: string
|
||||
env?: ReadonlyArray<ActionEnvVar>
|
||||
requires_sudo?: boolean
|
||||
}
|
||||
|
||||
export async function fetchActions(
|
||||
@@ -958,6 +986,9 @@ export type NodeActionItem = {
|
||||
kind?: ActionKind
|
||||
body?: string
|
||||
env?: ReadonlyArray<ActionEnvVar>
|
||||
requires_sudo?: boolean
|
||||
set_variable?: string
|
||||
run_if?: string
|
||||
}
|
||||
|
||||
export type NodeActionGroup = {
|
||||
@@ -983,10 +1014,17 @@ export type ActionItemRunResult = {
|
||||
item_id: string
|
||||
action_name: string
|
||||
source: string
|
||||
kind?: ActionKind
|
||||
remote_command?: string
|
||||
ssh_username?: string
|
||||
exit_code: number
|
||||
stdout: string
|
||||
stderr: string
|
||||
error?: string
|
||||
skipped?: boolean
|
||||
skip_reason?: string
|
||||
set_variable?: string
|
||||
variable_value?: string
|
||||
started_at: string
|
||||
finished_at: string
|
||||
}
|
||||
@@ -1048,6 +1086,9 @@ export type CreateActionGroupItemRequest = {
|
||||
kind?: ActionKind
|
||||
body?: string
|
||||
env?: ReadonlyArray<ActionEnvVar>
|
||||
requires_sudo?: boolean
|
||||
set_variable?: string
|
||||
run_if?: string
|
||||
}
|
||||
|
||||
export type PatchActionGroupItemRequest = {
|
||||
@@ -1056,6 +1097,9 @@ export type PatchActionGroupItemRequest = {
|
||||
kind?: ActionKind
|
||||
body?: string
|
||||
env?: ReadonlyArray<ActionEnvVar>
|
||||
requires_sudo?: boolean
|
||||
set_variable?: string
|
||||
run_if?: string
|
||||
}
|
||||
|
||||
export async function fetchActionGroups(
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
--ctp-mauve: #8839ef;
|
||||
--ctp-red: #d20f39;
|
||||
--ctp-green: #40a02b;
|
||||
--ctp-yellow: #df8e1d;
|
||||
--ctp-blue: #1e66f5;
|
||||
--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));
|
||||
@@ -35,6 +36,7 @@
|
||||
--ctp-mauve: #cba6f7;
|
||||
--ctp-red: #f38ba8;
|
||||
--ctp-green: #a6e3a1;
|
||||
--ctp-yellow: #f9e2af;
|
||||
--ctp-blue: #89b4fa;
|
||||
--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));
|
||||
|
||||
Reference in New Issue
Block a user