Wire action-group variables, sudo, and command path resolution through the API and UI.

Completes run-if/set_variable and requires_sudo editing, sudoers path resolve helpers, and execute permission checks so node action groups can gate upgrades and elevate safely.
This commit is contained in:
2026-07-19 22:00:14 +02:00
parent 0e06063c1d
commit 7631591f30
22 changed files with 2685 additions and 274 deletions
+73 -3
View File
@@ -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,6 +51,9 @@ 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 {
@@ -58,6 +62,9 @@ type patchActionGroupItemRequest 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 reorderActionGroupItemsRequest struct { type reorderActionGroupItemsRequest struct {
@@ -325,11 +332,13 @@ 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 item.Source == settings.ActionItemSourceLocal {
if payload.Name != nil { if payload.Name != nil {
item.Name = strings.TrimSpace(*payload.Name) item.Name = strings.TrimSpace(*payload.Name)
} }
@@ -345,6 +354,9 @@ func (app *App) actionGroupItemsPatchHandler(writer http.ResponseWriter, request
if payload.Env != nil { if payload.Env != nil {
item.Env = *payload.Env item.Env = *payload.Env
} }
if payload.RequiresSudo != nil {
item.RequiresSudo = *payload.RequiresSudo
}
if item.Env == nil { if item.Env == nil {
item.Env = []settings.ActionEnvVar{} item.Env = []settings.ActionEnvVar{}
} }
@@ -352,6 +364,24 @@ func (app *App) actionGroupItemsPatchHandler(writer http.ResponseWriter, request
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()}) writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return 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].Items[itemIndex] = item
store.Groups[groupIndex].UpdatedAt = time.Now().UTC() store.Groups[groupIndex].UpdatedAt = time.Now().UTC()
@@ -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
@@ -730,12 +771,41 @@ func buildNodeActionItem(payload createActionGroupItemRequest) (settings.NodeAct
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)
}
}
+6
View File
@@ -22,6 +22,7 @@ type createActionRequest 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"`
} }
type patchActionRequest struct { type patchActionRequest struct {
@@ -29,6 +30,7 @@ type patchActionRequest struct {
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 {
@@ -221,6 +226,7 @@ func buildCustomAction(payload createActionRequest) (settings.Action, error) {
Kind: payload.Kind, Kind: payload.Kind,
Body: body, Body: body,
Env: env, Env: env,
RequiresSudo: payload.RequiresSudo,
Builtin: false, Builtin: false,
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
@@ -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 {
+25
View File
@@ -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 {
+5
View File
@@ -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 {
+1
View File
@@ -31,6 +31,7 @@ var allowedPermissions = map[string]struct{}{
"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": {},
@@ -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,
})
}
+1
View File
@@ -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
}
+101
View File
@@ -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)
}
}
+10 -3
View File
@@ -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
View File
@@ -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);
}
+3
View File
@@ -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',
+27
View File
@@ -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">
File diff suppressed because it is too large Load Diff
+188
View File
@@ -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',
])
})
})
+194
View File
@@ -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
}
+44
View File
@@ -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(
+2
View File
@@ -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));