Compare commits

..
10 Commits
Author SHA1 Message Date
Squid aac9e82766 Add per-node health checks with status indicators and scheduled probes. 2026-07-19 22:40:21 +02:00
Squid 5307ba1a7b Align READMEs with SSH host management and current config layout. 2026-07-19 22:07:27 +02:00
Squid 7631591f30 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.
2026-07-19 22:00:14 +02:00
Squid 0e06063c1d Clarify run-if skip reasons with the resolved variable value.
Failed conditions now explain skips in plain language (e.g. UpdateCount was 0) and truncate long captures so misconfigured apt listings cannot flood the UI.
2026-07-19 21:59:35 +02:00
Squid eade2ef0c7 Highlight sudo actions in red and show a pointer cursor on expandable log rows. 2026-07-19 21:14:17 +02:00
Squid 32af91fd30 Add per-node action groups with schedules, runs, and logs.
Let operators manage ordered action groups on each host, run them manually or on a schedule over SSH, and inspect disk-backed JSON run logs from a node detail UI.
2026-07-19 20:01:11 +02:00
Squid 50568127c5 page title 2026-07-18 22:26:38 +02:00
Squid 463aa9a7a3 Add activity logs, sidebar badges, and logout with working alert colors.
Record auth audits and surface them in Activity, poll sidebar counts without resetting idle, and add a profile logout path plus theme red/green/blue tokens so failure badges render.
2026-07-18 21:43:44 +02:00
Squid e3793f380c Show a login notice when a session ends due to idle timeout.
Return a distinct session_idle error from the API and reload to /login with a clear signed-out message.
2026-07-18 20:50:52 +02:00
Squid c90a47c3ea Add Configuration Actions tab for shell and script action definitions.
Admins can manage built-in and custom actions with placeholders, ENV vars, and actions.create/update/delete permissions.
2026-07-18 19:18:13 +02:00
70 changed files with 14793 additions and 314 deletions
+21 -5
View File
@@ -1,6 +1,14 @@
# ClusterCanvas
A web-based dashboard for managing Proxmox VMs, LXC containers, and Docker. After configuration it discovers hosts and shows perinstance details like hostname, storage and RAM usage, and status. From the same interface you can view logs, run updates, deploy changes, and perform other routine maintenance tasks.
A web UI and Go API for managing remote hosts over SSH. You register hosts manually (grouped as containers, VMs, or Docker for navigation), run shell or script actions on them—including scheduled action groups—and administer users, groups, and sessions with a first-run setup wizard, optional TOTP, and audit logs.
## Features
- **SSH hosts** — Register nodes with generated or imported keys, test connectivity, and organize them under Containers / VMs / Docker categories
- **Action library** — Global shell/script actions (seeded builtins: Uptime, Disk usage, System info) plus custom actions with env vars and optional sudo
- **Action groups** — Per-node ordered items (library refs or local copies), schedules (interval or clock times), `set_variable` / `run_if`, and run logs
- **Auth & access** — Setup wizard, session cookies, group permissions, optional TOTP, and reauth for sensitive operations
- **Activity** — Node and auth audit trails, plus per-node action run history
## Layout
@@ -11,7 +19,7 @@ A web-based dashboard for managing Proxmox VMs, LXC containers, and Docker. Afte
## Prerequisites
- Go 1.22+ (toolchain may download a newer Go as needed)
- Go 1.25+ (toolchain may download a newer Go as needed)
- Node.js 20+ and npm
## Configuration
@@ -20,10 +28,17 @@ Service config lives under **`/etc/ClusterCanvas/`** by default:
| File | Purpose |
|------|---------|
| `settings.json` | Plain base settings (non-secret), including setup/network/security/groups |
| `settings.json` | Setup, network, security, and groups (plain JSON) |
| `passwords.enc` | AES-GCM encrypted user credentials (argon2id hashes, TOTP secrets) |
| `sessions.enc` | AES-GCM encrypted session store |
| `secrets.enc` | AES-GCM encrypted JSON secrets (integrations, etc.) |
| `secrets.enc` | AES-GCM encrypted secrets placeholder (integrations not wired yet) |
| `nodes.json` | Registered SSH hosts |
| `node-keys.enc` | AES-GCM encrypted SSH private keys |
| `actions.json` | Global action library |
| `node-action-groups.json` | Per-node action groups and schedules |
| `node-audit.json` | Node mutation / SSH-test audit trail |
| `auth-audit.json` | Auth / user / group audit trail |
| `action-logs/` | Per-node action run history |
**Config directory precedence** (highest first):
@@ -49,7 +64,7 @@ export CLUSTERCANVAS_CONFIG_KEY="$(openssl rand -base64 32)"
make dev
```
Keep the same key across restarts if you already ran setup — changing it makes existing `passwords.enc` / `sessions.enc` unreadable.
Keep the same key across restarts if you already ran setup — changing it makes existing `passwords.enc` / `sessions.enc` / `node-keys.enc` unreadable.
### First-run setup
@@ -111,6 +126,7 @@ REMOTEDEV_USER=youruser REMOTEDEV_HOST=192.168.1.50 make remotedev
What lands on the VM: `clustercanvas-server`, `clustercanvas-webui`, and `web/` (built SPA). Config is stored under `$(REMOTEDEV_DIR)/config` with the remotedev encryption key so the setup wizard works without extra env setup. Transfer uses `scp` (OpenSSH only on the remote—no `rsync` required). Re-running `make remotedev` stops any previous instance on the VM before uploading so binaries are not busy. The webui binary serves the SPA and proxies `/api` and `/health` to the API. Open `http://<vm-ip>:5173`. Ctrl+C stops both remote processes. A reverse proxy in front of these ports is optional and separate.
**Do not use `REMOTEDEV_CONFIG_KEY` (or its default) in production** — generate a unique key with `openssl rand -base64 32` instead.
## Test
```bash
+9 -1
View File
@@ -36,9 +36,10 @@ func main() {
listenHost := settings.ListenHost(configDir)
listenAddr := listenHost + ":" + port
handler, app := api.NewRouterWithApp(configDir)
server := &http.Server{
Addr: listenAddr,
Handler: api.NewRouter(configDir),
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
}
@@ -53,6 +54,13 @@ func main() {
signal.Notify(stopSignals, syscall.SIGINT, syscall.SIGTERM)
<-stopSignals
if app.Executor != nil {
app.Executor.StopScheduler()
}
if app.HealthChecker != nil {
app.HealthChecker.StopScheduler()
}
shutdownContext, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@@ -0,0 +1,871 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"os"
"strings"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/runner"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
type actionGroupsResponse struct {
Groups []settings.NodeActionGroup `json:"groups"`
}
type actionGroupResponse struct {
Group settings.NodeActionGroup `json:"group"`
}
type actionRunResponse struct {
Run settings.ActionGroupRunRecord `json:"run"`
}
type actionLogsListResponse struct {
Files []settings.ActionLogFileInfo `json:"files"`
}
type actionLogFileResponse struct {
Log settings.ActionLogFile `json:"log"`
}
type createActionGroupRequest struct {
Name string `json:"name"`
Schedule *settings.ActionGroupSchedule `json:"schedule"`
}
type patchActionGroupRequest struct {
Name *string `json:"name"`
Schedule *settings.ActionGroupSchedule `json:"schedule"`
}
type createActionGroupItemRequest struct {
Source settings.ActionItemSource `json:"source"`
LibraryActionID string `json:"library_action_id"`
Name string `json:"name"`
Description string `json:"description"`
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 {
Name *string `json:"name"`
Description *string `json:"description"`
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 {
ItemIDs []string `json:"item_ids"`
}
func (app *App) actionGroupsListHandler(writer http.ResponseWriter, request *http.Request) {
node, ok := app.loadAccessibleNode(writer, request)
if !ok {
return
}
store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, actionGroupsResponse{Groups: settings.GroupsForNode(store, node.ID)})
}
func (app *App) actionGroupsCreateHandler(writer http.ResponseWriter, request *http.Request) {
if err := authorizeActionsCreate(request, app.ConfigDir); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
node, ok := app.loadAccessibleNode(writer, request)
if !ok {
return
}
var payload createActionGroupRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
name := strings.TrimSpace(payload.Name)
if name == "" {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "group name is required"})
return
}
schedule := settings.ActionGroupSchedule{
Enabled: false,
Kind: settings.ActionGroupScheduleInterval,
Times: []settings.ActionGroupTimeSpec{},
}
if payload.Schedule != nil {
schedule = *payload.Schedule
if schedule.Times == nil {
schedule.Times = []settings.ActionGroupTimeSpec{}
}
}
if err := validateSchedule(schedule); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
groupID, err := auth.NewUUID()
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
now := time.Now().UTC()
group := settings.NodeActionGroup{
ID: groupID,
NodeID: node.ID,
Name: name,
Schedule: schedule,
Items: []settings.NodeActionItem{},
CreatedAt: now,
UpdatedAt: now,
}
store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
store.Groups = append(store.Groups, group)
if err := settings.SaveNodeActionGroups(app.ConfigDir, store); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, actionGroupResponse{Group: group})
}
func (app *App) actionGroupsPatchHandler(writer http.ResponseWriter, request *http.Request) {
if err := authorizeActionsUpdate(request, app.ConfigDir); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
node, ok := app.loadAccessibleNode(writer, request)
if !ok {
return
}
groupID := strings.TrimSpace(request.PathValue("gid"))
var payload patchActionGroupRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
index := settings.FindNodeActionGroupIndex(store.Groups, groupID)
if index < 0 || store.Groups[index].NodeID != node.ID {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action group not found"})
return
}
group := store.Groups[index]
if payload.Name != nil {
name := strings.TrimSpace(*payload.Name)
if name == "" {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "group name is required"})
return
}
group.Name = name
}
if payload.Schedule != nil {
schedule := *payload.Schedule
if schedule.Times == nil {
schedule.Times = []settings.ActionGroupTimeSpec{}
}
if err := validateSchedule(schedule); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
group.Schedule = schedule
}
group.UpdatedAt = time.Now().UTC()
store.Groups[index] = group
if err := settings.SaveNodeActionGroups(app.ConfigDir, store); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, actionGroupResponse{Group: group})
}
func (app *App) actionGroupsDeleteHandler(writer http.ResponseWriter, request *http.Request) {
if err := authorizeActionsDelete(request, app.ConfigDir); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
node, ok := app.loadAccessibleNode(writer, request)
if !ok {
return
}
groupID := strings.TrimSpace(request.PathValue("gid"))
store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
index := settings.FindNodeActionGroupIndex(store.Groups, groupID)
if index < 0 || store.Groups[index].NodeID != node.ID {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action group not found"})
return
}
store.Groups = append(store.Groups[:index], store.Groups[index+1:]...)
if err := settings.SaveNodeActionGroups(app.ConfigDir, store); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, actionGroupsResponse{Groups: settings.GroupsForNode(store, node.ID)})
}
func (app *App) actionGroupItemsCreateHandler(writer http.ResponseWriter, request *http.Request) {
if err := authorizeActionsCreate(request, app.ConfigDir); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
node, ok := app.loadAccessibleNode(writer, request)
if !ok {
return
}
groupID := strings.TrimSpace(request.PathValue("gid"))
var payload createActionGroupItemRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
item, err := buildNodeActionItem(payload)
if err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
index := settings.FindNodeActionGroupIndex(store.Groups, groupID)
if index < 0 || store.Groups[index].NodeID != node.ID {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action group not found"})
return
}
if item.Source == settings.ActionItemSourceLibrary {
library, libErr := settings.LoadActionsOrSeed(app.ConfigDir)
if libErr != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: libErr.Error()})
return
}
found := false
for _, action := range library.Actions {
if action.ID == item.LibraryActionID {
found = true
break
}
}
if !found {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "library action not found"})
return
}
}
store.Groups[index].Items = append(store.Groups[index].Items, item)
store.Groups[index].UpdatedAt = time.Now().UTC()
if err := settings.SaveNodeActionGroups(app.ConfigDir, store); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, actionGroupResponse{Group: store.Groups[index]})
}
func (app *App) actionGroupItemsPatchHandler(writer http.ResponseWriter, request *http.Request) {
if err := authorizeActionsUpdate(request, app.ConfigDir); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
node, ok := app.loadAccessibleNode(writer, request)
if !ok {
return
}
groupID := strings.TrimSpace(request.PathValue("gid"))
itemID := strings.TrimSpace(request.PathValue("iid"))
var payload patchActionGroupItemRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
groupIndex := settings.FindNodeActionGroupIndex(store.Groups, groupID)
if groupIndex < 0 || store.Groups[groupIndex].NodeID != node.ID {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action group not found"})
return
}
itemIndex := settings.FindNodeActionItemIndex(store.Groups[groupIndex].Items, itemID)
if itemIndex < 0 {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action item not found"})
return
}
item := store.Groups[groupIndex].Items[itemIndex]
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)
}
if payload.Description != nil {
item.Description = strings.TrimSpace(*payload.Description)
}
if payload.Kind != nil {
item.Kind = *payload.Kind
}
if payload.Body != nil {
item.Body = strings.TrimSpace(*payload.Body)
}
if payload.Env != nil {
item.Env = *payload.Env
}
if payload.RequiresSudo != nil {
item.RequiresSudo = *payload.RequiresSudo
}
if item.Env == nil {
item.Env = []settings.ActionEnvVar{}
}
if err := validateActionFields(item.Name, item.Kind, item.Body, item.Env); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
}
if payload.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()
if err := settings.SaveNodeActionGroups(app.ConfigDir, store); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, actionGroupResponse{Group: store.Groups[groupIndex]})
}
func (app *App) actionGroupItemsCloneHandler(writer http.ResponseWriter, request *http.Request) {
if err := authorizeActionsCreate(request, app.ConfigDir); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
node, ok := app.loadAccessibleNode(writer, request)
if !ok {
return
}
groupID := strings.TrimSpace(request.PathValue("gid"))
itemID := strings.TrimSpace(request.PathValue("iid"))
store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
groupIndex := settings.FindNodeActionGroupIndex(store.Groups, groupID)
if groupIndex < 0 || store.Groups[groupIndex].NodeID != node.ID {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action group not found"})
return
}
itemIndex := settings.FindNodeActionItemIndex(store.Groups[groupIndex].Items, itemID)
if itemIndex < 0 {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action item not found"})
return
}
item := store.Groups[groupIndex].Items[itemIndex]
if item.Source != settings.ActionItemSourceLibrary {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "only library actions can be cloned"})
return
}
library, err := settings.LoadActionsOrSeed(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
var libraryAction settings.Action
found := false
for _, action := range library.Actions {
if action.ID == item.LibraryActionID {
libraryAction = action
found = true
break
}
}
if !found {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "library action not found"})
return
}
env := libraryAction.Env
if env == nil {
env = []settings.ActionEnvVar{}
}
item.Source = settings.ActionItemSourceLocal
item.LibraryActionID = ""
item.Name = libraryAction.Name
item.Description = libraryAction.Description
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()
if err := settings.SaveNodeActionGroups(app.ConfigDir, store); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, actionGroupResponse{Group: store.Groups[groupIndex]})
}
func (app *App) actionGroupItemsDeleteHandler(writer http.ResponseWriter, request *http.Request) {
if err := authorizeActionsDelete(request, app.ConfigDir); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
node, ok := app.loadAccessibleNode(writer, request)
if !ok {
return
}
groupID := strings.TrimSpace(request.PathValue("gid"))
itemID := strings.TrimSpace(request.PathValue("iid"))
store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
groupIndex := settings.FindNodeActionGroupIndex(store.Groups, groupID)
if groupIndex < 0 || store.Groups[groupIndex].NodeID != node.ID {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action group not found"})
return
}
itemIndex := settings.FindNodeActionItemIndex(store.Groups[groupIndex].Items, itemID)
if itemIndex < 0 {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action item not found"})
return
}
items := store.Groups[groupIndex].Items
store.Groups[groupIndex].Items = append(items[:itemIndex], items[itemIndex+1:]...)
store.Groups[groupIndex].UpdatedAt = time.Now().UTC()
if err := settings.SaveNodeActionGroups(app.ConfigDir, store); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, actionGroupResponse{Group: store.Groups[groupIndex]})
}
func (app *App) actionGroupItemsOrderHandler(writer http.ResponseWriter, request *http.Request) {
if err := authorizeActionsUpdate(request, app.ConfigDir); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
node, ok := app.loadAccessibleNode(writer, request)
if !ok {
return
}
groupID := strings.TrimSpace(request.PathValue("gid"))
var payload reorderActionGroupItemsRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
groupIndex := settings.FindNodeActionGroupIndex(store.Groups, groupID)
if groupIndex < 0 || store.Groups[groupIndex].NodeID != node.ID {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action group not found"})
return
}
current := store.Groups[groupIndex].Items
if len(payload.ItemIDs) != len(current) {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "item_ids must include every item exactly once"})
return
}
byID := make(map[string]settings.NodeActionItem, len(current))
for _, item := range current {
byID[item.ID] = item
}
reordered := make([]settings.NodeActionItem, 0, len(payload.ItemIDs))
seen := make(map[string]struct{}, len(payload.ItemIDs))
for _, itemID := range payload.ItemIDs {
item, exists := byID[itemID]
if !exists {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "unknown item id in order list"})
return
}
if _, dup := seen[itemID]; dup {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "duplicate item id in order list"})
return
}
seen[itemID] = struct{}{}
reordered = append(reordered, item)
}
store.Groups[groupIndex].Items = reordered
store.Groups[groupIndex].UpdatedAt = time.Now().UTC()
if err := settings.SaveNodeActionGroups(app.ConfigDir, store); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, actionGroupResponse{Group: store.Groups[groupIndex]})
}
func (app *App) actionGroupsRunHandler(writer http.ResponseWriter, request *http.Request) {
if err := authorizeActionsExecute(request, app.ConfigDir); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
node, ok := app.loadAccessibleNode(writer, request)
if !ok {
return
}
groupID := strings.TrimSpace(request.PathValue("gid"))
group, found := app.findGroupForNode(node.ID, groupID)
if !found {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action group not found"})
return
}
if app.Executor == nil {
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: "executor unavailable"})
return
}
actor := ""
if user, ok := UserFromContext(request.Context()); ok {
actor = user.Username
}
record, err := app.Executor.RunGroup(group, settings.ActionRunTriggerManual, actor)
if err != nil {
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, actionRunResponse{Run: record})
}
func (app *App) actionGroupItemsRunHandler(writer http.ResponseWriter, request *http.Request) {
if err := authorizeActionsExecute(request, app.ConfigDir); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
node, ok := app.loadAccessibleNode(writer, request)
if !ok {
return
}
groupID := strings.TrimSpace(request.PathValue("gid"))
itemID := strings.TrimSpace(request.PathValue("iid"))
group, found := app.findGroupForNode(node.ID, groupID)
if !found {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action group not found"})
return
}
itemIndex := settings.FindNodeActionItemIndex(group.Items, itemID)
if itemIndex < 0 {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action item not found"})
return
}
if app.Executor == nil {
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: "executor unavailable"})
return
}
actor := ""
if user, ok := UserFromContext(request.Context()); ok {
actor = user.Username
}
record, err := app.Executor.RunItem(group, group.Items[itemIndex], settings.ActionRunTriggerManual, actor)
if err != nil {
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, actionRunResponse{Run: record})
}
func (app *App) actionLogsListHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.authorizeLogsRead(request); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
node, ok := app.loadAccessibleNode(writer, request)
if !ok {
return
}
files, err := settings.ListActionLogFiles(app.ConfigDir, node.ID)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, actionLogsListResponse{Files: files})
}
func (app *App) actionLogsGetHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.authorizeLogsRead(request); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
node, ok := app.loadAccessibleNode(writer, request)
if !ok {
return
}
filename := strings.TrimSpace(request.PathValue("filename"))
logFile, err := settings.ReadActionLogFileByName(app.ConfigDir, node.ID, filename)
if err != nil {
if strings.Contains(err.Error(), "invalid log filename") {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
if errors.Is(err, os.ErrNotExist) {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "log file not found"})
return
}
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "log file not found"})
return
}
writeJSON(writer, http.StatusOK, actionLogFileResponse{Log: logFile})
}
func (app *App) loadAccessibleNode(writer http.ResponseWriter, request *http.Request) (settings.Node, bool) {
if err := app.authorizeNodesRead(request); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return settings.Node{}, false
}
user, ok := UserFromContext(request.Context())
if !ok {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "authentication required"})
return settings.Node{}, false
}
nodeID := strings.TrimSpace(request.PathValue("id"))
if nodeID == "" {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "missing node id"})
return settings.Node{}, false
}
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return settings.Node{}, false
}
store, err := settings.LoadNodesOrEmpty(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return settings.Node{}, false
}
for _, node := range store.Nodes {
if node.ID != nodeID {
continue
}
if !userCanAccessNode(user, settingsPayload.Groups, node) {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: "permission denied"})
return settings.Node{}, false
}
return node, true
}
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "node not found"})
return settings.Node{}, false
}
func (app *App) findGroupForNode(nodeID string, groupID string) (settings.NodeActionGroup, bool) {
store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir)
if err != nil {
return settings.NodeActionGroup{}, false
}
index := settings.FindNodeActionGroupIndex(store.Groups, groupID)
if index < 0 || store.Groups[index].NodeID != nodeID {
return settings.NodeActionGroup{}, false
}
return store.Groups[index], true
}
func buildNodeActionItem(payload createActionGroupItemRequest) (settings.NodeActionItem, error) {
itemID, err := auth.NewUUID()
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)
if libraryID == "" {
return settings.NodeActionItem{}, errors.New("library_action_id is required")
}
return settings.NodeActionItem{
ID: itemID,
Source: settings.ActionItemSourceLibrary,
LibraryActionID: libraryID,
Env: []settings.ActionEnvVar{},
SetVariable: setVariable,
RunIf: runIf,
}, nil
case settings.ActionItemSourceLocal:
env := payload.Env
if env == nil {
env = []settings.ActionEnvVar{}
}
name := strings.TrimSpace(payload.Name)
body := strings.TrimSpace(payload.Body)
if err := validateActionFields(name, payload.Kind, body, env); err != nil {
return settings.NodeActionItem{}, err
}
return settings.NodeActionItem{
ID: itemID,
Source: settings.ActionItemSourceLocal,
Name: name,
Description: strings.TrimSpace(payload.Description),
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:
if schedule.Enabled && schedule.EverySeconds < 1 {
return errors.New("every_seconds must be at least 1 when interval schedule is enabled")
}
return nil
case settings.ActionGroupScheduleTimes:
if schedule.Enabled && len(schedule.Times) == 0 {
return errors.New("at least one time is required when times schedule is enabled")
}
for _, spec := range schedule.Times {
hour, minute, ok := parseScheduleHHMM(spec.Time)
if !ok {
return errors.New("time must be HH:MM")
}
_ = hour
_ = minute
for _, day := range spec.DaysOfWeek {
if day < 0 || day > 6 {
return errors.New("days_of_week values must be 0-6")
}
}
}
return nil
case "":
return errors.New("schedule kind is required")
default:
return errors.New("schedule kind must be interval or times")
}
}
func parseScheduleHHMM(value string) (hour int, minute int, ok bool) {
parts := strings.Split(strings.TrimSpace(value), ":")
if len(parts) != 2 {
return 0, 0, false
}
if _, err := fmtSscanf(parts[0], &hour); err != nil {
return 0, 0, false
}
if _, err := fmtSscanf(parts[1], &minute); err != nil {
return 0, 0, false
}
if hour < 0 || hour > 23 || minute < 0 || minute > 59 {
return 0, 0, false
}
return hour, minute, true
}
func fmtSscanf(input string, dest *int) (int, error) {
n := 0
for _, ch := range input {
if ch < '0' || ch > '9' {
return 0, errors.New("not a number")
}
n = n*10 + int(ch-'0')
}
if input == "" {
return 0, errors.New("empty")
}
*dest = n
return 1, nil
}
@@ -0,0 +1,615 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
func seedNodeForActions(t *testing.T, configDir string) settings.Node {
t.Helper()
node := settings.Node{
ID: "11111111-1111-4111-8111-111111111111",
Kind: settings.NodeKindContainer,
Name: "box-1",
HostIP: "127.0.0.1",
Username: "root",
GroupName: settings.AdministratorsGroupName,
PublicKey: "ssh-ed25519 AAAA",
KeyAlgo: "ed25519",
CreatedAt: time.Now().UTC(),
}
if err := settings.SaveNodes(configDir, settings.NodeStore{Nodes: []settings.Node{node}}); err != nil {
t.Fatalf("SaveNodes: %v", err)
}
return node
}
func TestActionGroupsCRUDCloneReorderAndAuthz(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": "Nightly",
"schedule": map[string]any{
"enabled": false,
"kind": "interval",
"every_seconds": 3600,
"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
addLibBody, _ := json.Marshal(map[string]any{
"source": "library",
"library_action_id": settings.BuiltinActionUptimeID,
})
addReq := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items",
bytes.NewReader(addLibBody),
),
cookie,
)
addRec := httptest.NewRecorder()
router.ServeHTTP(addRec, addReq)
if addRec.Code != http.StatusOK {
t.Fatalf("add item status=%d body=%s", addRec.Code, addRec.Body.String())
}
var withItem actionGroupResponse
if err := json.Unmarshal(addRec.Body.Bytes(), &withItem); err != nil {
t.Fatalf("decode add: %v", err)
}
if len(withItem.Group.Items) != 1 || withItem.Group.Items[0].Source != settings.ActionItemSourceLibrary {
t.Fatalf("items = %#v", withItem.Group.Items)
}
itemID := withItem.Group.Items[0].ID
addCustomBody, _ := json.Marshal(map[string]any{
"source": "local",
"name": "Echo",
"kind": "shell",
"body": "echo hi",
"env": []any{},
})
addCustomReq := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items",
bytes.NewReader(addCustomBody),
),
cookie,
)
addCustomRec := httptest.NewRecorder()
router.ServeHTTP(addCustomRec, addCustomReq)
if addCustomRec.Code != http.StatusOK {
t.Fatalf("add custom status=%d body=%s", addCustomRec.Code, addCustomRec.Body.String())
}
var withTwo actionGroupResponse
_ = json.Unmarshal(addCustomRec.Body.Bytes(), &withTwo)
if len(withTwo.Group.Items) != 2 {
t.Fatalf("expected 2 items, got %d", len(withTwo.Group.Items))
}
customID := withTwo.Group.Items[1].ID
orderBody, _ := json.Marshal(map[string]any{
"item_ids": []string{customID, itemID},
})
orderReq := withSession(
httptest.NewRequest(
http.MethodPut,
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items/order",
bytes.NewReader(orderBody),
),
cookie,
)
orderRec := httptest.NewRecorder()
router.ServeHTTP(orderRec, orderReq)
if orderRec.Code != http.StatusOK {
t.Fatalf("reorder status=%d body=%s", orderRec.Code, orderRec.Body.String())
}
var reordered actionGroupResponse
_ = json.Unmarshal(orderRec.Body.Bytes(), &reordered)
if reordered.Group.Items[0].ID != customID || reordered.Group.Items[1].ID != itemID {
t.Fatalf("order = %#v", reordered.Group.Items)
}
cloneReq := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/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)
var clonedItem settings.NodeActionItem
for _, item := range cloned.Group.Items {
if item.ID == itemID {
clonedItem = item
break
}
}
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(
httptest.NewRequest(http.MethodGet, "/api/v1/nodes/"+node.ID+"/action-groups", nil),
cookie,
)
listRec := httptest.NewRecorder()
router.ServeHTTP(listRec, listReq)
if listRec.Code != http.StatusOK {
t.Fatalf("list status=%d", listRec.Code)
}
logsReq := withSession(
httptest.NewRequest(http.MethodGet, "/api/v1/nodes/"+node.ID+"/action-logs", nil),
cookie,
)
logsRec := httptest.NewRecorder()
router.ServeHTTP(logsRec, logsReq)
if logsRec.Code != http.StatusOK {
t.Fatalf("logs list status=%d body=%s", logsRec.Code, logsRec.Body.String())
}
}
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)
node := seedNodeForActions(t, configDir)
// Downgrade Administrators permissions to nodes.read only.
payload, err := settings.LoadSettings(configDir)
if err != nil {
t.Fatalf("LoadSettings: %v", err)
}
payload.Groups[0].Permissions = []string{"nodes.read"}
if err := settings.SaveSettings(configDir, payload); err != nil {
t.Fatalf("SaveSettings: %v", err)
}
router := NewRouter(configDir)
body, _ := json.Marshal(map[string]any{"name": "Nope"})
req := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/nodes/"+node.ID+"/action-groups",
bytes.NewReader(body),
),
cookie,
)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
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)
}
}
+267
View File
@@ -0,0 +1,267 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"regexp"
"strings"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
type actionsResponse struct {
Actions []settings.Action `json:"actions"`
}
type createActionRequest struct {
Name string `json:"name"`
Description string `json:"description"`
Kind settings.ActionKind `json:"kind"`
Body string `json:"body"`
Env []settings.ActionEnvVar `json:"env"`
RequiresSudo bool `json:"requires_sudo"`
}
type patchActionRequest struct {
Name *string `json:"name"`
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_]*$`)
var (
errActionNameRequired = errors.New("action name is required")
errActionBodyRequired = errors.New("action body is required")
errActionKindInvalid = errors.New("action kind must be shell or script")
errActionNotFound = errors.New("action not found")
errActionBuiltinRO = errors.New("built-in actions cannot be modified")
errActionIDRequired = errors.New("action id is required")
)
func actionsGetHandler(configDir string) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
store, err := settings.LoadActionsOrSeed(configDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, actionsResponse{Actions: store.Actions})
}
}
func actionsCreateHandler(configDir string) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
if err := authorizeActionsCreate(request, configDir); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
var payload createActionRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
action, err := buildCustomAction(payload)
if err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
store, err := settings.LoadActionsOrSeed(configDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
store.Actions = append(store.Actions, action)
if err := settings.SaveActions(configDir, store); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, actionsResponse{Actions: store.Actions})
}
}
func actionsPatchHandler(configDir string) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
if err := authorizeActionsUpdate(request, configDir); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
actionID := strings.TrimSpace(request.URL.Query().Get("id"))
if actionID == "" {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: errActionIDRequired.Error()})
return
}
var payload patchActionRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
store, err := settings.LoadActionsOrSeed(configDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
index := findActionIndex(store.Actions, actionID)
if index < 0 {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: errActionNotFound.Error()})
return
}
if store.Actions[index].Builtin {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: errActionBuiltinRO.Error()})
return
}
updated := store.Actions[index]
if payload.Name != nil {
updated.Name = strings.TrimSpace(*payload.Name)
}
if payload.Description != nil {
updated.Description = strings.TrimSpace(*payload.Description)
}
if payload.Body != nil {
updated.Body = strings.TrimSpace(*payload.Body)
}
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 {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
if updated.Env == nil {
updated.Env = []settings.ActionEnvVar{}
}
store.Actions[index] = updated
if err := settings.SaveActions(configDir, store); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, actionsResponse{Actions: store.Actions})
}
}
func actionsDeleteHandler(configDir string) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
if err := authorizeActionsDelete(request, configDir); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
actionID := strings.TrimSpace(request.URL.Query().Get("id"))
if actionID == "" {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: errActionIDRequired.Error()})
return
}
store, err := settings.LoadActionsOrSeed(configDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
index := findActionIndex(store.Actions, actionID)
if index < 0 {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: errActionNotFound.Error()})
return
}
if store.Actions[index].Builtin {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: errActionBuiltinRO.Error()})
return
}
store.Actions = append(store.Actions[:index], store.Actions[index+1:]...)
if err := settings.SaveActions(configDir, store); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, actionsResponse{Actions: store.Actions})
}
}
func buildCustomAction(payload createActionRequest) (settings.Action, error) {
name := strings.TrimSpace(payload.Name)
description := strings.TrimSpace(payload.Description)
body := strings.TrimSpace(payload.Body)
env := payload.Env
if env == nil {
env = []settings.ActionEnvVar{}
}
if err := validateActionFields(name, payload.Kind, body, env); err != nil {
return settings.Action{}, err
}
actionID, err := auth.NewUUID()
if err != nil {
return settings.Action{}, err
}
now := time.Now().UTC()
return settings.Action{
ID: actionID,
Name: name,
Description: description,
Kind: payload.Kind,
Body: body,
Env: env,
RequiresSudo: payload.RequiresSudo,
Builtin: false,
CreatedAt: now,
UpdatedAt: now,
}, nil
}
func validateActionFields(
name string,
kind settings.ActionKind,
body string,
env []settings.ActionEnvVar,
) error {
if name == "" {
return errActionNameRequired
}
if body == "" {
return errActionBodyRequired
}
if kind != settings.ActionKindShell && kind != settings.ActionKindScript {
return errActionKindInvalid
}
for index := range env {
env[index].Name = strings.TrimSpace(env[index].Name)
if env[index].Name == "" || !envVarNamePattern.MatchString(env[index].Name) {
return errors.New("environment variable name must match [A-Za-z_][A-Za-z0-9_]*")
}
}
return nil
}
func findActionIndex(actions []settings.Action, actionID string) int {
for index := range actions {
if actions[index].ID == actionID {
return index
}
}
return -1
}
@@ -0,0 +1,232 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
type actionsResponseTest struct {
Actions []settings.Action `json:"actions"`
}
func TestActionsGetSeedsBuiltins(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/actions", nil), cookie)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String())
}
var payload actionsResponseTest
if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(payload.Actions) != 3 {
t.Fatalf("expected 3 builtins, got %d", len(payload.Actions))
}
for _, action := range payload.Actions {
if !action.Builtin {
t.Fatalf("expected builtin, got %#v", action)
}
}
}
func TestActionsCreatePatchDelete(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
createBody := []byte(`{
"name": "Echo host",
"description": "Print the target IP",
"kind": "shell",
"body": "echo {{node.ip}}",
"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()
router.ServeHTTP(createRec, createReq)
if createRec.Code != http.StatusOK {
t.Fatalf("create status %d body=%s", createRec.Code, createRec.Body.String())
}
var created actionsResponseTest
if err := json.NewDecoder(createRec.Body).Decode(&created); err != nil {
t.Fatalf("decode create: %v", err)
}
if len(created.Actions) != 4 {
t.Fatalf("expected 4 actions after create, got %d", len(created.Actions))
}
custom := created.Actions[3]
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}}","requires_sudo":false}`)
patchReq := withSession(
httptest.NewRequest(http.MethodPatch, "/api/v1/actions?id="+custom.ID, bytes.NewReader(patchBody)),
cookie,
)
patchRec := httptest.NewRecorder()
router.ServeHTTP(patchRec, patchReq)
if patchRec.Code != http.StatusOK {
t.Fatalf("patch status %d body=%s", patchRec.Code, patchRec.Body.String())
}
var patched actionsResponseTest
if err := json.NewDecoder(patchRec.Body).Decode(&patched); err != nil {
t.Fatalf("decode patch: %v", err)
}
found := false
for _, action := range patched.Actions {
if action.ID == custom.ID {
found = true
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 {
t.Fatal("patched action missing")
}
deleteReq := withSession(
httptest.NewRequest(http.MethodDelete, "/api/v1/actions?id="+custom.ID, nil),
cookie,
)
deleteRec := httptest.NewRecorder()
router.ServeHTTP(deleteRec, deleteReq)
if deleteRec.Code != http.StatusOK {
t.Fatalf("delete status %d body=%s", deleteRec.Code, deleteRec.Body.String())
}
var deleted actionsResponseTest
if err := json.NewDecoder(deleteRec.Body).Decode(&deleted); err != nil {
t.Fatalf("decode delete: %v", err)
}
if len(deleted.Actions) != 3 {
t.Fatalf("expected 3 after delete, got %d", len(deleted.Actions))
}
}
func TestActionsRejectBuiltinMutationAndInvalidInput(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
getReq := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/actions", nil), cookie)
getRec := httptest.NewRecorder()
router.ServeHTTP(getRec, getReq)
var listed actionsResponseTest
if err := json.NewDecoder(getRec.Body).Decode(&listed); err != nil {
t.Fatalf("decode list: %v", err)
}
builtinID := listed.Actions[0].ID
patchBody := []byte(`{"name":"Nope"}`)
patchReq := withSession(
httptest.NewRequest(http.MethodPatch, "/api/v1/actions?id="+builtinID, bytes.NewReader(patchBody)),
cookie,
)
patchRec := httptest.NewRecorder()
router.ServeHTTP(patchRec, patchReq)
if patchRec.Code != http.StatusBadRequest {
t.Fatalf("expected builtin patch 400, got %d", patchRec.Code)
}
deleteReq := withSession(
httptest.NewRequest(http.MethodDelete, "/api/v1/actions?id="+builtinID, nil),
cookie,
)
deleteRec := httptest.NewRecorder()
router.ServeHTTP(deleteRec, deleteReq)
if deleteRec.Code != http.StatusBadRequest {
t.Fatalf("expected builtin delete 400, got %d", deleteRec.Code)
}
badCreate := []byte(`{"name":"x","kind":"shell","body":"echo","env":[{"name":"bad-name","value":"1"}]}`)
badReq := withSession(httptest.NewRequest(http.MethodPost, "/api/v1/actions", bytes.NewReader(badCreate)), cookie)
badRec := httptest.NewRecorder()
router.ServeHTTP(badRec, badReq)
if badRec.Code != http.StatusBadRequest {
t.Fatalf("expected invalid env 400, got %d", badRec.Code)
}
invalidKind := []byte(`{"name":"x","kind":"nope","body":"echo"}`)
kindReq := withSession(httptest.NewRequest(http.MethodPost, "/api/v1/actions", bytes.NewReader(invalidKind)), cookie)
kindRec := httptest.NewRecorder()
router.ServeHTTP(kindRec, kindReq)
if kindRec.Code != http.StatusBadRequest {
t.Fatalf("expected invalid kind 400, got %d", kindRec.Code)
}
}
func TestActionsMutationsRequirePermissions(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
// Strip action permissions from Administrators.
settingsPayload, err := settings.LoadSettings(configDir)
if err != nil {
t.Fatalf("LoadSettings: %v", err)
}
for index := range settingsPayload.Groups {
if settingsPayload.Groups[index].Name != settings.AdministratorsGroupName {
continue
}
filtered := make([]string, 0, len(settingsPayload.Groups[index].Permissions))
for _, permission := range settingsPayload.Groups[index].Permissions {
if permission == "actions.create" || permission == "actions.update" || permission == "actions.delete" {
continue
}
filtered = append(filtered, permission)
}
settingsPayload.Groups[index].Permissions = filtered
}
if err := settings.SaveSettings(configDir, settingsPayload); err != nil {
t.Fatalf("SaveSettings: %v", err)
}
createBody := []byte(`{"name":"x","kind":"shell","body":"echo"}`)
createReq := withSession(httptest.NewRequest(http.MethodPost, "/api/v1/actions", bytes.NewReader(createBody)), cookie)
createRec := httptest.NewRecorder()
router.ServeHTTP(createRec, createReq)
if createRec.Code != http.StatusForbidden {
t.Fatalf("expected create 403, got %d body=%s", createRec.Code, createRec.Body.String())
}
patchReq := withSession(
httptest.NewRequest(http.MethodPatch, "/api/v1/actions?id=custom", bytes.NewReader([]byte(`{"name":"y"}`))),
cookie,
)
patchRec := httptest.NewRecorder()
router.ServeHTTP(patchRec, patchReq)
if patchRec.Code != http.StatusForbidden {
t.Fatalf("expected patch 403, got %d", patchRec.Code)
}
deleteReq := withSession(httptest.NewRequest(http.MethodDelete, "/api/v1/actions?id=custom", nil), cookie)
deleteRec := httptest.NewRecorder()
router.ServeHTTP(deleteRec, deleteReq)
if deleteRec.Code != http.StatusForbidden {
t.Fatalf("expected delete 403, got %d", deleteRec.Code)
}
}
+66
View File
@@ -0,0 +1,66 @@
package api
import (
"net/http"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
type authLogsResponse struct {
Events []settings.AuthAuditEvent `json:"events"`
}
func (app *App) authLogsListHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.authorizeLogsRead(request); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
store, err := settings.LoadAuthAuditOrEmpty(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
filtered := make([]settings.AuthAuditEvent, 0, len(store.Events))
for index := len(store.Events) - 1; index >= 0; index-- {
filtered = append(filtered, store.Events[index])
}
writeJSON(writer, http.StatusOK, authLogsResponse{Events: filtered})
}
func appendAuthAudit(
configDir string,
action string,
outcome settings.AuthAuditOutcome,
category settings.AuthAuditCategory,
actor string,
target string,
detail string,
) {
eventID, err := auth.NewUUID()
if err != nil {
return
}
_ = settings.AppendAuthAuditEvent(configDir, settings.AuthAuditEvent{
ID: eventID,
At: time.Now().UTC(),
Action: action,
Outcome: outcome,
Category: category,
Actor: actor,
Target: target,
Detail: detail,
})
}
func actorUsernameFromRequest(request *http.Request) string {
user, ok := UserFromContext(request.Context())
if !ok {
return ""
}
return user.Username
}
+236
View File
@@ -0,0 +1,236 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
func TestAuthLogsRequiresLogsRead(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
payload, err := settings.LoadSettings(configDir)
if err != nil {
t.Fatalf("LoadSettings: %v", err)
}
for index := range payload.Groups {
if payload.Groups[index].Name != settings.AdministratorsGroupName {
continue
}
// Strip logs.read and the permissions that would trigger migration.
filtered := make([]string, 0)
for _, permission := range payload.Groups[index].Permissions {
switch permission {
case "logs.read", "nodes.read", "users.manage":
continue
}
filtered = append(filtered, permission)
}
payload.Groups[index].Permissions = filtered
}
if err := settings.SaveSettings(configDir, payload); err != nil {
t.Fatalf("SaveSettings: %v", err)
}
router := NewRouter(configDir)
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/auth-logs", nil), cookie)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusForbidden {
t.Fatalf("expected %d, got %d body=%s", http.StatusForbidden, recorder.Code, recorder.Body.String())
}
}
func TestAuthLogsAllowsLogsReadWithoutUsersManage(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
payload, err := settings.LoadSettings(configDir)
if err != nil {
t.Fatalf("LoadSettings: %v", err)
}
for index := range payload.Groups {
if payload.Groups[index].Name != settings.AdministratorsGroupName {
continue
}
payload.Groups[index].Permissions = []string{"logs.read"}
}
if err := settings.SaveSettings(configDir, payload); err != nil {
t.Fatalf("SaveSettings: %v", err)
}
router := NewRouter(configDir)
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/auth-logs", nil), cookie)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String())
}
}
func TestAuthLogsRecordsLoginAndUserCreate(t *testing.T) {
configDir := t.TempDir()
_ = seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
failBody := []byte(`{"username":"Admin","password":"wrong password that fails"}`)
failReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(failBody))
failRec := httptest.NewRecorder()
router.ServeHTTP(failRec, failReq)
if failRec.Code != http.StatusUnauthorized {
t.Fatalf("failed login status %d body %s", failRec.Code, failRec.Body.String())
}
okBody := []byte(`{"username":"Admin","password":"correct horse battery staple extra"}`)
okReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(okBody))
okRec := httptest.NewRecorder()
router.ServeHTTP(okRec, okReq)
if okRec.Code != http.StatusOK {
t.Fatalf("login status %d body %s", okRec.Code, okRec.Body.String())
}
var loginCookie *http.Cookie
for _, candidate := range okRec.Result().Cookies() {
if candidate.Name == auth.SessionCookieName {
loginCookie = candidate
break
}
}
if loginCookie == nil {
t.Fatal("login did not set session cookie")
}
createReq := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/users",
strings.NewReader(`{"username":"alice","password":"correct horse battery staple extra","group_names":[]}`),
),
loginCookie,
)
createReq.Header.Set("Content-Type", "application/json")
createRec := httptest.NewRecorder()
router.ServeHTTP(createRec, createReq)
if createRec.Code != http.StatusOK {
t.Fatalf("create user status %d body %s", createRec.Code, createRec.Body.String())
}
logsReq := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/auth-logs", nil), loginCookie)
logsRec := httptest.NewRecorder()
router.ServeHTTP(logsRec, logsReq)
if logsRec.Code != http.StatusOK {
t.Fatalf("auth-logs status %d body %s", logsRec.Code, logsRec.Body.String())
}
var logs authLogsResponse
if err := json.Unmarshal(logsRec.Body.Bytes(), &logs); err != nil {
t.Fatalf("decode: %v", err)
}
if len(logs.Events) < 3 {
t.Fatalf("expected at least 3 events, got %d", len(logs.Events))
}
var sawLoginFailure, sawLoginSuccess, sawUserCreate bool
for _, event := range logs.Events {
switch {
case event.Action == "login" && event.Outcome == settings.AuthAuditOutcomeFailure:
sawLoginFailure = true
if event.Detail != "invalid credentials" {
t.Fatalf("failure detail = %q", event.Detail)
}
case event.Action == "login" && event.Outcome == settings.AuthAuditOutcomeSuccess:
sawLoginSuccess = true
if event.Actor != "Admin" {
t.Fatalf("login actor = %q", event.Actor)
}
case event.Action == "user_create" && event.Target == "alice":
sawUserCreate = true
if event.Category != settings.AuthAuditCategoryUser {
t.Fatalf("category = %q", event.Category)
}
}
}
if !sawLoginFailure || !sawLoginSuccess || !sawUserCreate {
t.Fatalf(
"missing events: failure=%v success=%v create=%v events=%+v",
sawLoginFailure,
sawLoginSuccess,
sawUserCreate,
logs.Events,
)
}
// Newest first: user_create should appear before older login events.
if logs.Events[0].Action != "user_create" {
t.Fatalf("expected newest event user_create, got %q", logs.Events[0].Action)
}
}
func TestMigrateLogsReadPermission(t *testing.T) {
payload := settings.Settings{
Groups: []settings.Group{
{Name: "ops", Permissions: []string{"nodes.read", "jobs.read"}},
{Name: "hr", Permissions: []string{"users.manage"}},
{Name: "auditors", Permissions: []string{"logs.read"}},
{Name: "viewers", Permissions: []string{"jobs.read"}},
},
}
if !migrateLogsReadPermission(&payload) {
t.Fatal("expected migration to change settings")
}
if !containsPermission(payload.Groups[0].Permissions, "logs.read") {
t.Fatal("ops should gain logs.read from nodes.read")
}
if !containsPermission(payload.Groups[1].Permissions, "logs.read") {
t.Fatal("hr should gain logs.read from users.manage")
}
if len(payload.Groups[2].Permissions) != 1 {
t.Fatal("auditors should be unchanged")
}
if containsPermission(payload.Groups[3].Permissions, "logs.read") {
t.Fatal("viewers should not gain logs.read")
}
if migrateLogsReadPermission(&payload) {
t.Fatal("second migration should be a no-op")
}
}
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 {
return true
}
}
return false
}
+87
View File
@@ -10,7 +10,14 @@ import (
var errUsersManageRequired = errors.New("permission users.manage required")
var errNodesReadRequired = errors.New("permission nodes.read required")
var errNodesExecRequired = errors.New("permission nodes.exec required")
var errNodesUpdateRequired = errors.New("permission nodes.update required")
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")
// permissionsForUser returns the union of permissions from the user's groups.
func permissionsForUser(user settings.UserCredential, groups []settings.Group) map[string]struct{} {
@@ -49,6 +56,22 @@ func permissionList(user settings.UserCredential, groups []settings.Group) []str
return result
}
func authorizeNamedPermission(request *http.Request, configDir string, permission string, missing error) error {
user, ok := UserFromContext(request.Context())
if !ok {
return errors.New("authentication required")
}
payload, err := loadSettingsOrDefault(configDir)
if err != nil {
return err
}
if !userHasPermission(user, payload.Groups, permission) {
return missing
}
return nil
}
func (app *App) authorizeUsersWrite(request *http.Request) error {
user, ok := UserFromContext(request.Context())
if !ok {
@@ -81,6 +104,22 @@ func (app *App) authorizeNodesRead(request *http.Request) error {
return nil
}
func (app *App) authorizeLogsRead(request *http.Request) error {
user, ok := UserFromContext(request.Context())
if !ok {
return errors.New("authentication required")
}
payload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
return err
}
if !userHasPermission(user, payload.Groups, "logs.read") {
return errLogsReadRequired
}
return nil
}
func (app *App) authorizeNodesExec(request *http.Request) error {
user, ok := UserFromContext(request.Context())
if !ok {
@@ -97,6 +136,22 @@ func (app *App) authorizeNodesExec(request *http.Request) error {
return nil
}
func (app *App) authorizeNodesUpdate(request *http.Request) error {
user, ok := UserFromContext(request.Context())
if !ok {
return errors.New("authentication required")
}
payload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
return err
}
if !userHasPermission(user, payload.Groups, "nodes.update") {
return errNodesUpdateRequired
}
return nil
}
func (app *App) authorizeNodesDelete(request *http.Request) error {
user, ok := UserFromContext(request.Context())
if !ok {
@@ -112,3 +167,35 @@ func (app *App) authorizeNodesDelete(request *http.Request) error {
}
return nil
}
func authorizeActionsCreate(request *http.Request, configDir string) error {
return authorizeNamedPermission(request, configDir, "actions.create", errActionsCreateRequired)
}
func authorizeActionsUpdate(request *http.Request, configDir string) error {
return authorizeNamedPermission(request, configDir, "actions.update", errActionsUpdateRequired)
}
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 {
return errors.New("authentication required")
}
payload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
return err
}
if !userHasPermission(user, payload.Groups, "jobs.run") {
return errJobsRunRequired
}
return nil
}
+27
View File
@@ -28,9 +28,14 @@ var allowedPermissions = map[string]struct{}{
"nodes.delete": {},
"jobs.read": {},
"jobs.run": {},
"actions.create": {},
"actions.update": {},
"actions.delete": {},
"actions.execute": {},
"users.manage": {},
"secrets.manage": {},
"roles.manage": {},
"logs.read": {},
}
func groupsGetHandler(configDir string) http.HandlerFunc {
@@ -86,6 +91,19 @@ func groupsUpsertHandler(configDir string) http.HandlerFunc {
return
}
action := "group_create"
if updated {
action = "group_update"
}
appendAuthAudit(
configDir,
action,
settings.AuthAuditOutcomeSuccess,
settings.AuthAuditCategoryGroup,
actorUsernameFromRequest(request),
payload.Group.Name,
"",
)
writeJSON(writer, http.StatusOK, groupsResponse{Groups: settingsPayload.Groups})
}
}
@@ -134,6 +152,15 @@ func groupsDeleteHandler(configDir string) http.HandlerFunc {
return
}
appendAuthAudit(
configDir,
"group_delete",
settings.AuthAuditOutcomeSuccess,
settings.AuthAuditCategoryGroup,
actorUsernameFromRequest(request),
name,
"",
)
writeJSON(writer, http.StatusOK, groupsResponse{Groups: settingsPayload.Groups})
}
}
+27
View File
@@ -140,6 +140,15 @@ func (app *App) mePasswordHandler(writer http.ResponseWriter, request *http.Requ
_ = app.Sessions.DestroySession(writer, request)
}
appendAuthAudit(
app.ConfigDir,
"password_change",
settings.AuthAuditOutcomeSuccess,
settings.AuthAuditCategorySelf,
user.Username,
user.Username,
"",
)
writeJSON(writer, http.StatusOK, map[string]bool{"ok": true})
}
@@ -257,6 +266,15 @@ func (app *App) meTOTPConfirmHandler(writer http.ResponseWriter, request *http.R
return
}
appendAuthAudit(
app.ConfigDir,
"totp_enable",
settings.AuthAuditOutcomeSuccess,
settings.AuthAuditCategorySelf,
user.Username,
user.Username,
"",
)
writeJSON(writer, http.StatusOK, map[string]bool{"ok": true})
}
@@ -319,5 +337,14 @@ func (app *App) meTOTPDisableHandler(writer http.ResponseWriter, request *http.R
return
}
appendAuthAudit(
app.ConfigDir,
"totp_disable",
settings.AuthAuditOutcomeSuccess,
settings.AuthAuditCategorySelf,
user.Username,
user.Username,
"",
)
writeJSON(writer, http.StatusOK, map[string]bool{"ok": true})
}
+11 -3
View File
@@ -2,6 +2,7 @@ package api
import (
"context"
"errors"
"net/http"
"strings"
@@ -9,6 +10,8 @@ import (
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
const sessionIdleExemptHeader = "X-Session-Idle-Exempt"
func (app *App) withMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
path := request.URL.Path
@@ -74,9 +77,14 @@ func (app *App) withMiddleware(next http.Handler) http.Handler {
}
security := effectiveSecurity(settingsPayload.Security)
session, err := app.Sessions.LookupValidSession(writer, request, security)
touchActivity := request.Header.Get(sessionIdleExemptHeader) != "1"
session, err := app.Sessions.LookupValidSession(writer, request, security, touchActivity)
if err != nil {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "authentication required"})
errorMessage := "authentication required"
if errors.Is(err, auth.ErrSessionIdle) {
errorMessage = "session_idle"
}
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: errorMessage})
return
}
@@ -137,7 +145,7 @@ func (app *App) withCORS(next http.Handler) http.Handler {
writer.Header().Set("Vary", "Origin")
}
writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
writer.Header().Set("Access-Control-Allow-Headers", "Content-Type")
writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, "+sessionIdleExemptHeader)
if request.Method == http.MethodOptions {
writer.WriteHeader(http.StatusNoContent)
@@ -0,0 +1,209 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
func TestMiddlewareReturnsSessionIdle(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
keyBytes := make([]byte, 32)
for index := range keyBytes {
keyBytes[index] = byte(index + 1)
}
store, err := settings.LoadSessions(configDir, keyBytes)
if err != nil {
t.Fatalf("LoadSessions: %v", err)
}
if len(store.Sessions) != 1 {
t.Fatalf("expected 1 session, got %d", len(store.Sessions))
}
store.Sessions[0].LastSeenAt = time.Now().UTC().Add(-31 * time.Minute)
if err := settings.SaveSessions(configDir, store, keyBytes); err != nil {
t.Fatalf("SaveSessions: %v", err)
}
router := NewRouter(configDir)
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/status", nil), cookie)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("expected status %d, got %d body=%s", http.StatusUnauthorized, recorder.Code, recorder.Body.String())
}
var payload apiErrorResponse
if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Error != "session_idle" {
t.Fatalf("expected session_idle, got %q", payload.Error)
}
cleared := false
for _, setCookie := range recorder.Result().Cookies() {
if setCookie.Name == auth.SessionCookieName && setCookie.MaxAge < 0 {
cleared = true
}
}
if !cleared {
t.Fatal("expected session cookie to be cleared")
}
}
func TestMiddlewareReturnsAuthenticationRequiredWithoutCookie(t *testing.T) {
configDir := t.TempDir()
_ = seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
request := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("expected status %d, got %d body=%s", http.StatusUnauthorized, recorder.Code, recorder.Body.String())
}
var payload apiErrorResponse
if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Error != "authentication required" {
t.Fatalf("expected authentication required, got %q", payload.Error)
}
}
func TestMiddlewareIdleExemptDoesNotAdvanceLastSeenAt(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
keyBytes := make([]byte, 32)
for index := range keyBytes {
keyBytes[index] = byte(index + 1)
}
store, err := settings.LoadSessions(configDir, keyBytes)
if err != nil {
t.Fatalf("LoadSessions: %v", err)
}
originalLastSeen := time.Now().UTC().Add(-10 * time.Minute).Truncate(time.Second)
store.Sessions[0].LastSeenAt = originalLastSeen
if err := settings.SaveSessions(configDir, store, keyBytes); err != nil {
t.Fatalf("SaveSessions: %v", err)
}
router := NewRouter(configDir)
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/status", nil), cookie)
request.Header.Set(sessionIdleExemptHeader, "1")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String())
}
store, err = settings.LoadSessions(configDir, keyBytes)
if err != nil {
t.Fatalf("LoadSessions after request: %v", err)
}
if len(store.Sessions) != 1 {
t.Fatalf("expected 1 session, got %d", len(store.Sessions))
}
if !store.Sessions[0].LastSeenAt.Equal(originalLastSeen) {
t.Fatalf(
"expected LastSeenAt %v unchanged, got %v",
originalLastSeen,
store.Sessions[0].LastSeenAt,
)
}
}
func TestMiddlewareIdleExemptStillReturnsSessionIdle(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
keyBytes := make([]byte, 32)
for index := range keyBytes {
keyBytes[index] = byte(index + 1)
}
store, err := settings.LoadSessions(configDir, keyBytes)
if err != nil {
t.Fatalf("LoadSessions: %v", err)
}
store.Sessions[0].LastSeenAt = time.Now().UTC().Add(-31 * time.Minute)
if err := settings.SaveSessions(configDir, store, keyBytes); err != nil {
t.Fatalf("SaveSessions: %v", err)
}
router := NewRouter(configDir)
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/status", nil), cookie)
request.Header.Set(sessionIdleExemptHeader, "1")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("expected status %d, got %d body=%s", http.StatusUnauthorized, recorder.Code, recorder.Body.String())
}
var payload apiErrorResponse
if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Error != "session_idle" {
t.Fatalf("expected session_idle, got %q", payload.Error)
}
}
func TestMiddlewareNormalRequestAdvancesLastSeenAt(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
keyBytes := make([]byte, 32)
for index := range keyBytes {
keyBytes[index] = byte(index + 1)
}
store, err := settings.LoadSessions(configDir, keyBytes)
if err != nil {
t.Fatalf("LoadSessions: %v", err)
}
originalLastSeen := time.Now().UTC().Add(-10 * time.Minute).Truncate(time.Second)
store.Sessions[0].LastSeenAt = originalLastSeen
if err := settings.SaveSessions(configDir, store, keyBytes); err != nil {
t.Fatalf("SaveSessions: %v", err)
}
router := NewRouter(configDir)
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/status", nil), cookie)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String())
}
store, err = settings.LoadSessions(configDir, keyBytes)
if err != nil {
t.Fatalf("LoadSessions after request: %v", err)
}
if len(store.Sessions) != 1 {
t.Fatalf("expected 1 session, got %d", len(store.Sessions))
}
if !store.Sessions[0].LastSeenAt.After(originalLastSeen) {
t.Fatalf(
"expected LastSeenAt after %v, got %v",
originalLastSeen,
store.Sessions[0].LastSeenAt,
)
}
}
+160 -46
View File
@@ -3,6 +3,7 @@ package api
import (
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"strings"
@@ -231,6 +232,164 @@ func (app *App) nodesTestSSHHandler(writer http.ResponseWriter, request *http.Re
})
}
type patchNodeRequest struct {
HealthCheckIntervalSeconds *int `json:"health_check_interval_seconds"`
}
func (app *App) nodesPatchHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.authorizeNodesUpdate(request); err != nil {
writeJSON(writer, http.StatusForbidden, 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 patchNodeRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
if payload.HealthCheckIntervalSeconds == nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{
Error: "health_check_interval_seconds is required",
})
return
}
if *payload.HealthCheckIntervalSeconds < 0 {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{
Error: "health_check_interval_seconds must be >= 0",
})
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
}
nodeIndex := -1
var node settings.Node
for index, candidate := range store.Nodes {
if candidate.ID != nodeID {
continue
}
node = candidate
nodeIndex = index
break
}
if nodeIndex < 0 {
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
}
node.HealthCheckIntervalSeconds = *payload.HealthCheckIntervalSeconds
store.Nodes[nodeIndex] = node
if err := settings.SaveNodes(app.ConfigDir, store); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
_ = appendNodeAudit(
app.ConfigDir,
settings.NodeAuditActionUpdate,
user.Username,
node,
fmt.Sprintf("health_check_interval_seconds=%d", node.HealthCheckIntervalSeconds),
)
writeJSON(writer, http.StatusOK, nodeResponse{Node: node})
}
func (app *App) nodesHealthCheckHandler(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
}
if app.HealthChecker == nil {
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: "health checker unavailable"})
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
}
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
}
updated, err := app.HealthChecker.CheckNodeByID(nodeID, user.Username)
if err != nil {
if strings.Contains(err.Error(), "already running") {
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, nodeResponse{Node: updated})
}
func (app *App) nodesCreateHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.authorizeNodesExec(request); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
@@ -450,50 +609,24 @@ func (app *App) nodesDeleteHandler(writer http.ResponseWriter, request *http.Req
}
func (app *App) nodeLogsListHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.authorizeNodesRead(request); err != nil {
if err := app.authorizeLogsRead(request); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
user, ok := UserFromContext(request.Context())
if !ok {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "authentication required"})
return
}
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
store, err := settings.LoadNodeAuditOrEmpty(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
nodesStore, err := settings.LoadNodesOrEmpty(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
nodeByID := make(map[string]settings.Node, len(nodesStore.Nodes))
for _, node := range nodesStore.Nodes {
nodeByID[node.ID] = node
}
kindFilter := strings.TrimSpace(request.URL.Query().Get("kind"))
isAdmin := userIsAdministrator(user)
filtered := make([]settings.NodeAuditEvent, 0, len(store.Events))
for index := len(store.Events) - 1; index >= 0; index-- {
event := store.Events[index]
if kindFilter != "" && string(event.NodeKind) != kindFilter {
continue
}
if !userCanSeeNodeAuditEvent(user, settingsPayload.Groups, nodeByID, event, isAdmin) {
continue
}
filtered = append(filtered, event)
}
@@ -609,25 +742,6 @@ func userCanAccessNode(user settings.UserCredential, groups []settings.Group, no
return false
}
func userCanSeeNodeAuditEvent(
user settings.UserCredential,
groups []settings.Group,
nodeByID map[string]settings.Node,
event settings.NodeAuditEvent,
isAdmin bool,
) bool {
if isAdmin {
return true
}
if event.Actor == user.Username {
return true
}
if node, ok := nodeByID[event.NodeID]; ok {
return userCanAccessNode(user, groups, node)
}
return false
}
func appendNodeAudit(
configDir string,
action settings.NodeAuditAction,
+138
View File
@@ -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)
@@ -644,3 +701,84 @@ func TestNodeLogsListFiltersByKind(t *testing.T) {
}
}
}
func TestNodeLogsRequiresLogsRead(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
payload, err := settings.LoadSettings(configDir)
if err != nil {
t.Fatalf("LoadSettings: %v", err)
}
for index := range payload.Groups {
if payload.Groups[index].Name != settings.AdministratorsGroupName {
continue
}
filtered := make([]string, 0)
for _, permission := range payload.Groups[index].Permissions {
switch permission {
case "logs.read", "nodes.read", "users.manage":
continue
}
filtered = append(filtered, permission)
}
payload.Groups[index].Permissions = filtered
}
if err := settings.SaveSettings(configDir, payload); err != nil {
t.Fatalf("SaveSettings: %v", err)
}
router := NewRouter(configDir)
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/node-logs", nil), cookie)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusForbidden {
t.Fatalf("expected %d, got %d body=%s", http.StatusForbidden, recorder.Code, recorder.Body.String())
}
}
func TestNodeLogsAllowsLogsReadWithoutNodesRead(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
payload, err := settings.LoadSettings(configDir)
if err != nil {
t.Fatalf("LoadSettings: %v", err)
}
for index := range payload.Groups {
if payload.Groups[index].Name != settings.AdministratorsGroupName {
continue
}
payload.Groups[index].Permissions = []string{"logs.read"}
}
if err := settings.SaveSettings(configDir, payload); err != nil {
t.Fatalf("SaveSettings: %v", err)
}
if err := settings.AppendNodeAuditEvent(configDir, settings.NodeAuditEvent{
ID: "evt-1",
At: time.Now().UTC(),
Action: settings.NodeAuditActionCreate,
Actor: "Admin",
NodeID: "node-1",
NodeName: "test-node",
NodeKind: settings.NodeKindContainer,
}); err != nil {
t.Fatalf("AppendNodeAuditEvent: %v", err)
}
router := NewRouter(configDir)
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/node-logs", nil), cookie)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String())
}
var logs nodeLogsResponse
if err := json.Unmarshal(recorder.Body.Bytes(), &logs); err != nil {
t.Fatalf("decode: %v", err)
}
if len(logs.Events) != 1 {
t.Fatalf("expected 1 event, got %d", len(logs.Events))
}
}
@@ -0,0 +1,211 @@
package api
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
)
func TestNodesHealthCheckPersistsStatus(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router, app := NewRouterWithApp(configDir)
app.HealthChecker.PingFn = func(hostIP string) auth.PingResult {
return auth.PingResult{OK: true, Method: auth.PingMethodICMP}
}
app.HealthChecker.SSHFn = func(hostIP string, username string, privateKeyPEM string, passphrase string) error {
return nil
}
createBody := []byte(`{
"id":"bbbbbbbb-bbbb-4ccc-8ddd-eeeeeeeeeeee",
"kind":"container",
"name":"healthy-node",
"host_ip":"10.20.30.40",
"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())
}
healthRequest := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/nodes/bbbbbbbb-bbbb-4ccc-8ddd-eeeeeeeeeeee/health-check",
nil,
),
cookie,
)
healthRecorder := httptest.NewRecorder()
router.ServeHTTP(healthRecorder, healthRequest)
if healthRecorder.Code != http.StatusOK {
t.Fatalf("health status = %d body=%s", healthRecorder.Code, healthRecorder.Body.String())
}
var response nodeResponse
if err := json.Unmarshal(healthRecorder.Body.Bytes(), &response); err != nil {
t.Fatalf("decode: %v", err)
}
if response.Node.HealthOK == nil || !*response.Node.HealthOK {
t.Fatalf("health_ok = %#v", response.Node.HealthOK)
}
if response.Node.HealthMessage == "" {
t.Fatal("expected health message")
}
}
func TestNodesHealthCheckRecordsFailure(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router, app := NewRouterWithApp(configDir)
app.HealthChecker.PingFn = func(hostIP string) auth.PingResult {
return auth.PingResult{
OK: false,
Method: auth.PingMethodTCP,
Error: errors.New("unreachable"),
}
}
app.HealthChecker.SSHFn = func(hostIP string, username string, privateKeyPEM string, passphrase string) error {
return errors.New("ssh failed")
}
createBody := []byte(`{
"id":"cccccccc-bbbb-4ccc-8ddd-eeeeeeeeeeee",
"kind":"vm",
"name":"unhealthy-node",
"host_ip":"10.20.30.41",
"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())
}
healthRequest := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/nodes/cccccccc-bbbb-4ccc-8ddd-eeeeeeeeeeee/health-check",
nil,
),
cookie,
)
healthRecorder := httptest.NewRecorder()
router.ServeHTTP(healthRecorder, healthRequest)
if healthRecorder.Code != http.StatusOK {
t.Fatalf("health status = %d body=%s", healthRecorder.Code, healthRecorder.Body.String())
}
var response nodeResponse
if err := json.Unmarshal(healthRecorder.Body.Bytes(), &response); err != nil {
t.Fatalf("decode: %v", err)
}
if response.Node.HealthOK == nil || *response.Node.HealthOK {
t.Fatalf("expected failure, got %#v", response.Node.HealthOK)
}
}
func TestNodesPatchHealthInterval(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
createBody := []byte(`{
"id":"dddddddd-bbbb-4ccc-8ddd-eeeeeeeeeeee",
"kind":"docker",
"name":"interval-node",
"host_ip":"10.20.30.42",
"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())
}
patchBody := []byte(`{"health_check_interval_seconds":120}`)
patchRequest := withSession(
httptest.NewRequest(
http.MethodPatch,
"/api/v1/nodes/dddddddd-bbbb-4ccc-8ddd-eeeeeeeeeeee",
bytes.NewReader(patchBody),
),
cookie,
)
patchRecorder := httptest.NewRecorder()
router.ServeHTTP(patchRecorder, patchRequest)
if patchRecorder.Code != http.StatusOK {
t.Fatalf("patch status = %d body=%s", patchRecorder.Code, patchRecorder.Body.String())
}
var response nodeResponse
if err := json.Unmarshal(patchRecorder.Body.Bytes(), &response); err != nil {
t.Fatalf("decode: %v", err)
}
if response.Node.HealthCheckIntervalSeconds != 120 {
t.Fatalf("interval = %d", response.Node.HealthCheckIntervalSeconds)
}
}
func TestNodesPatchHealthIntervalRejectsNegative(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
createBody := []byte(`{
"id":"eeeeeeee-bbbb-4ccc-8ddd-eeeeeeeeeeee",
"kind":"container",
"name":"bad-interval",
"host_ip":"10.20.30.43",
"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())
}
patchBody := []byte(`{"health_check_interval_seconds":-5}`)
patchRequest := withSession(
httptest.NewRequest(
http.MethodPatch,
"/api/v1/nodes/eeeeeeee-bbbb-4ccc-8ddd-eeeeeeeeeeee",
bytes.NewReader(patchBody),
),
cookie,
)
patchRecorder := httptest.NewRecorder()
router.ServeHTTP(patchRecorder, patchRequest)
if patchRecorder.Code != http.StatusBadRequest {
t.Fatalf("status = %d body=%s", patchRecorder.Code, patchRecorder.Body.String())
}
}
@@ -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,
})
}
+34 -1
View File
@@ -8,11 +8,23 @@ const defaultAllowedOrigin = "http://localhost:5173"
// NewRouter builds the HTTP API with setup gating, auth, and network middleware.
func NewRouter(configDir string) http.Handler {
handler, _ := NewRouterWithApp(configDir)
return handler
}
// NewRouterWithApp builds the router and returns the App for lifecycle control (scheduler stop).
func NewRouterWithApp(configDir string) (http.Handler, *App) {
app, err := newApp(configDir)
if err != nil {
// newApp currently always succeeds; keep signature for future.
panic(err)
}
if app.Executor != nil {
app.Executor.StartScheduler()
}
if app.HealthChecker != nil {
app.HealthChecker.StartScheduler()
}
mux := http.NewServeMux()
mux.HandleFunc("GET /health", HealthHandler)
@@ -37,6 +49,10 @@ func NewRouter(configDir string) http.Handler {
mux.HandleFunc("GET /api/v1/groups", groupsGetHandler(configDir))
mux.HandleFunc("POST /api/v1/groups", groupsUpsertHandler(configDir))
mux.HandleFunc("DELETE /api/v1/groups", groupsDeleteHandler(configDir))
mux.HandleFunc("GET /api/v1/actions", actionsGetHandler(configDir))
mux.HandleFunc("POST /api/v1/actions", actionsCreateHandler(configDir))
mux.HandleFunc("PATCH /api/v1/actions", actionsPatchHandler(configDir))
mux.HandleFunc("DELETE /api/v1/actions", actionsDeleteHandler(configDir))
mux.HandleFunc("GET /api/v1/security", securityGetHandler(configDir))
mux.HandleFunc("PUT /api/v1/security", securityPutHandler(configDir))
mux.HandleFunc("GET /api/v1/network", networkGetHandler(configDir))
@@ -49,9 +65,26 @@ func NewRouter(configDir string) http.Handler {
mux.HandleFunc("GET /api/v1/nodes", app.nodesListHandler)
mux.HandleFunc("POST /api/v1/nodes", app.nodesCreateHandler)
mux.HandleFunc("GET /api/v1/nodes/{id}", app.nodesGetHandler)
mux.HandleFunc("PATCH /api/v1/nodes/{id}", app.nodesPatchHandler)
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}/health-check", app.nodesHealthCheckHandler)
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)
mux.HandleFunc("DELETE /api/v1/nodes/{id}/action-groups/{gid}", app.actionGroupsDeleteHandler)
mux.HandleFunc("POST /api/v1/nodes/{id}/action-groups/{gid}/items", app.actionGroupItemsCreateHandler)
mux.HandleFunc("PATCH /api/v1/nodes/{id}/action-groups/{gid}/items/{iid}", app.actionGroupItemsPatchHandler)
mux.HandleFunc("POST /api/v1/nodes/{id}/action-groups/{gid}/items/{iid}/clone", app.actionGroupItemsCloneHandler)
mux.HandleFunc("DELETE /api/v1/nodes/{id}/action-groups/{gid}/items/{iid}", app.actionGroupItemsDeleteHandler)
mux.HandleFunc("PUT /api/v1/nodes/{id}/action-groups/{gid}/items/order", app.actionGroupItemsOrderHandler)
mux.HandleFunc("POST /api/v1/nodes/{id}/action-groups/{gid}/run", app.actionGroupsRunHandler)
mux.HandleFunc("POST /api/v1/nodes/{id}/action-groups/{gid}/items/{iid}/run", app.actionGroupItemsRunHandler)
mux.HandleFunc("GET /api/v1/nodes/{id}/action-logs", app.actionLogsListHandler)
mux.HandleFunc("GET /api/v1/nodes/{id}/action-logs/{filename}", app.actionLogsGetHandler)
mux.HandleFunc("GET /api/v1/node-logs", app.nodeLogsListHandler)
mux.HandleFunc("GET /api/v1/auth-logs", app.authLogsListHandler)
return app.withCORS(app.withMiddleware(mux))
return app.withCORS(app.withMiddleware(mux)), app
}
+130 -4
View File
@@ -11,6 +11,8 @@ import (
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/health"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/runner"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
@@ -79,6 +81,8 @@ type App struct {
ConfigDir string
Key []byte
Sessions *auth.SessionManager
Executor *runner.Executor
HealthChecker *health.Checker
pendingTOTPMu sync.Mutex
pendingTOTP map[string]string // username -> secret (setup only)
@@ -88,17 +92,23 @@ func newApp(configDir string) (*App, error) {
key, err := settings.KeyFromEnv()
if err != nil {
// Key is required for setup complete / auth; status still works without it.
return &App{
app := &App{
ConfigDir: configDir,
pendingTOTP: map[string]string{},
}, nil
Executor: runner.NewExecutor(configDir, nil),
HealthChecker: health.NewChecker(configDir, nil),
}
return &App{
return app, nil
}
app := &App{
ConfigDir: configDir,
Key: key,
Sessions: auth.NewSessionManager(configDir, key),
pendingTOTP: map[string]string{},
}, nil
Executor: runner.NewExecutor(configDir, key),
HealthChecker: health.NewChecker(configDir, key),
}
return app, nil
}
func (app *App) requireKey() error {
@@ -360,13 +370,32 @@ func (app *App) loginHandler(writer http.ResponseWriter, request *http.Request)
break
}
}
attemptedUsername := strings.TrimSpace(payload.Username)
if matched == nil || !matched.Enabled {
appendAuthAudit(
app.ConfigDir,
"login",
settings.AuthAuditOutcomeFailure,
settings.AuthAuditCategoryAuth,
attemptedUsername,
attemptedUsername,
"invalid credentials",
)
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid credentials"})
return
}
ok, err := auth.VerifyPassword(payload.Password, matched.PasswordHash)
if err != nil || !ok {
appendAuthAudit(
app.ConfigDir,
"login",
settings.AuthAuditOutcomeFailure,
settings.AuthAuditCategoryAuth,
attemptedUsername,
matched.Username,
"invalid credentials",
)
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid credentials"})
return
}
@@ -381,6 +410,15 @@ func (app *App) loginHandler(writer http.ResponseWriter, request *http.Request)
requiresTOTP := security.TotpEnabled && matched.TOTPConfirmed && matched.TOTPSecret != ""
if requiresTOTP {
if !auth.VerifyTOTPCode(matched.TOTPSecret, payload.TOTPCode) {
appendAuthAudit(
app.ConfigDir,
"login",
settings.AuthAuditOutcomeFailure,
settings.AuthAuditCategoryAuth,
matched.Username,
matched.Username,
"invalid or missing TOTP code",
)
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid or missing TOTP code"})
return
}
@@ -391,13 +429,34 @@ func (app *App) loginHandler(writer http.ResponseWriter, request *http.Request)
return
}
appendAuthAudit(
app.ConfigDir,
"login",
settings.AuthAuditOutcomeSuccess,
settings.AuthAuditCategoryAuth,
matched.Username,
matched.Username,
"",
)
writeJSON(writer, http.StatusOK, app.buildMeResponse(*matched, settingsPayload))
}
func (app *App) logoutHandler(writer http.ResponseWriter, request *http.Request) {
actor := actorUsernameFromRequest(request)
if app.Sessions != nil {
_ = app.Sessions.DestroySession(writer, request)
}
if actor != "" {
appendAuthAudit(
app.ConfigDir,
"logout",
settings.AuthAuditOutcomeSuccess,
settings.AuthAuditCategoryAuth,
actor,
actor,
"",
)
}
writeJSON(writer, http.StatusOK, map[string]bool{"ok": true})
}
@@ -453,9 +512,14 @@ func allPermissionList() []string {
"nodes.delete",
"jobs.read",
"jobs.run",
"actions.create",
"actions.update",
"actions.delete",
"actions.execute",
"users.manage",
"secrets.manage",
"roles.manage",
"logs.read",
}
_ = list
return ordered
@@ -481,5 +545,67 @@ 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
}
}
return payload, nil
}
// migrateLogsReadPermission grants logs.read to groups that previously could
// view logs via nodes.read or users.manage. Returns true when settings changed.
func migrateLogsReadPermission(payload *settings.Settings) bool {
changed := false
for index := range payload.Groups {
group := &payload.Groups[index]
hasLogsRead := false
hadLogAccess := false
for _, permission := range group.Permissions {
switch permission {
case "logs.read":
hasLogsRead = true
case "nodes.read", "users.manage":
hadLogAccess = true
}
}
if hasLogsRead || !hadLogAccess {
continue
}
group.Permissions = append(group.Permissions, "logs.read")
changed = true
}
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
}
+29
View File
@@ -137,6 +137,15 @@ func (app *App) usersCreateHandler(writer http.ResponseWriter, request *http.Req
return
}
appendAuthAudit(
app.ConfigDir,
"user_create",
settings.AuthAuditOutcomeSuccess,
settings.AuthAuditCategoryUser,
actorUsernameFromRequest(request),
username,
"",
)
writeJSON(writer, http.StatusOK, usersResponse{Users: toPublicUsers(store.Users)})
}
@@ -254,6 +263,15 @@ func (app *App) usersPatchHandler(writer http.ResponseWriter, request *http.Requ
_ = app.Sessions.InvalidateUserSessions(userID)
}
appendAuthAudit(
app.ConfigDir,
"user_update",
settings.AuthAuditOutcomeSuccess,
settings.AuthAuditCategoryUser,
actorUsernameFromRequest(request),
target.Username,
"",
)
writeJSON(writer, http.StatusOK, usersResponse{Users: toPublicUsers(store.Users)})
}
@@ -293,12 +311,14 @@ func (app *App) usersDeleteHandler(writer http.ResponseWriter, request *http.Req
adminCount := countAdministrators(store.Users)
remaining := make([]settings.UserCredential, 0, len(store.Users))
found := false
deletedUsername := ""
for _, user := range store.Users {
if user.ID != userID {
remaining = append(remaining, user)
continue
}
found = true
deletedUsername = user.Username
if userIsAdministrator(user) && adminCount <= 1 {
writeJSON(writer, http.StatusConflict, apiErrorResponse{
Error: "cannot delete the last administrator",
@@ -322,6 +342,15 @@ func (app *App) usersDeleteHandler(writer http.ResponseWriter, request *http.Req
_ = app.Sessions.InvalidateUserSessions(userID)
}
appendAuthAudit(
app.ConfigDir,
"user_delete",
settings.AuthAuditOutcomeSuccess,
settings.AuthAuditCategoryUser,
actorUsernameFromRequest(request),
deletedUsername,
"",
)
writeJSON(writer, http.StatusOK, usersResponse{Users: toPublicUsers(store.Users)})
}
+89
View File
@@ -0,0 +1,89 @@
package auth
import (
"fmt"
"net"
"os/exec"
"strings"
"time"
)
const (
PingMethodICMP = "icmp"
PingMethodTCP = "tcp"
defaultPingCount = "1"
defaultPingWaitSec = "2"
defaultTCPTimeout = 3 * time.Second
)
// PingResult is the outcome of a reachability probe.
type PingResult struct {
OK bool
Method string
Error error
}
type pingCommandFunc func(hostIP string) error
type tcpDialFunc func(address string, timeout time.Duration) error
var (
runICMPPing = defaultRunICMPPing
dialTCP = defaultDialTCP
)
// PingHost probes host reachability: ICMP first, then TCP :22 when ICMP is
// unavailable or the host does not respond to ICMP.
func PingHost(hostIP string) PingResult {
hostIP = strings.TrimSpace(hostIP)
if hostIP == "" {
return PingResult{OK: false, Error: fmt.Errorf("host IP is required")}
}
icmpErr := runICMPPing(hostIP)
if icmpErr == nil {
return PingResult{OK: true, Method: PingMethodICMP}
}
address := net.JoinHostPort(hostIP, defaultSSHPort)
tcpErr := dialTCP(address, defaultTCPTimeout)
if tcpErr == nil {
return PingResult{OK: true, Method: PingMethodTCP}
}
return PingResult{
OK: false,
Method: PingMethodTCP,
Error: fmt.Errorf(
"icmp: %v; tcp %s: %w",
icmpErr,
address,
tcpErr,
),
}
}
func defaultRunICMPPing(hostIP string) error {
pingPath, err := exec.LookPath("ping")
if err != nil {
return fmt.Errorf("ping unavailable: %w", err)
}
cmd := exec.Command(pingPath, "-c", defaultPingCount, "-W", defaultPingWaitSec, hostIP)
output, runErr := cmd.CombinedOutput()
if runErr == nil {
return nil
}
combined := strings.TrimSpace(string(output) + " " + runErr.Error())
return fmt.Errorf("%s", strings.TrimSpace(combined))
}
func defaultDialTCP(address string, timeout time.Duration) error {
conn, err := net.DialTimeout("tcp", address, timeout)
if err != nil {
return err
}
_ = conn.Close()
return nil
}
+96
View File
@@ -0,0 +1,96 @@
package auth
import (
"errors"
"testing"
"time"
)
func TestPingHostUsesICMPWhenAvailable(t *testing.T) {
originalPing := runICMPPing
originalDial := dialTCP
t.Cleanup(func() {
runICMPPing = originalPing
dialTCP = originalDial
})
runICMPPing = func(hostIP string) error {
if hostIP != "10.0.0.1" {
t.Fatalf("host = %q", hostIP)
}
return nil
}
dialTCP = func(address string, timeout time.Duration) error {
t.Fatal("tcp should not be used when icmp succeeds")
return nil
}
result := PingHost("10.0.0.1")
if !result.OK {
t.Fatalf("expected ok, got %#v", result)
}
if result.Method != PingMethodICMP {
t.Fatalf("method = %q", result.Method)
}
}
func TestPingHostFallsBackToTCPWhenICMPFails(t *testing.T) {
originalPing := runICMPPing
originalDial := dialTCP
t.Cleanup(func() {
runICMPPing = originalPing
dialTCP = originalDial
})
runICMPPing = func(hostIP string) error {
return errors.New("ping unavailable: executable file not found")
}
dialTCP = func(address string, timeout time.Duration) error {
if address != "10.0.0.2:22" {
t.Fatalf("address = %q", address)
}
return nil
}
result := PingHost("10.0.0.2")
if !result.OK {
t.Fatalf("expected ok, got %#v", result)
}
if result.Method != PingMethodTCP {
t.Fatalf("method = %q", result.Method)
}
}
func TestPingHostFailsWhenBothFail(t *testing.T) {
originalPing := runICMPPing
originalDial := dialTCP
t.Cleanup(func() {
runICMPPing = originalPing
dialTCP = originalDial
})
runICMPPing = func(hostIP string) error {
return errors.New("100% packet loss")
}
dialTCP = func(address string, timeout time.Duration) error {
return errors.New("connection refused")
}
result := PingHost("10.0.0.3")
if result.OK {
t.Fatal("expected failure")
}
if result.Error == nil {
t.Fatal("expected error")
}
if result.Method != PingMethodTCP {
t.Fatalf("method = %q", result.Method)
}
}
func TestPingHostRequiresHostIP(t *testing.T) {
result := PingHost(" ")
if result.OK || result.Error == nil {
t.Fatalf("expected failure, got %#v", result)
}
}
+53
View File
@@ -0,0 +1,53 @@
package auth
import (
"regexp"
"strings"
)
var placeholderPattern = regexp.MustCompile(`\{\{\s*([a-zA-Z0-9_.]+)\s*\}\}`)
// PlaceholderContext holds values used to expand action body placeholders.
type PlaceholderContext struct {
Host string
IP string
Username string
Env map[string]string
Secrets map[string]string
}
// ExpandPlaceholders replaces {{cc.host}}, {{node.ip}}, {{env.NAME}}, etc.
// Unknown or unresolved secrets expand to empty strings.
func ExpandPlaceholders(body string, ctx PlaceholderContext) string {
return placeholderPattern.ReplaceAllStringFunc(body, func(match string) string {
submatches := placeholderPattern.FindStringSubmatch(match)
if len(submatches) < 2 {
return match
}
key := strings.TrimSpace(submatches[1])
switch key {
case "cc.host", "node.host":
return ctx.Host
case "cc.ip", "node.ip":
return ctx.IP
case "node.username":
return ctx.Username
default:
if strings.HasPrefix(key, "env.") {
name := strings.TrimPrefix(key, "env.")
if ctx.Env != nil {
return ctx.Env[name]
}
return ""
}
if strings.HasPrefix(key, "secret.") {
name := strings.TrimPrefix(key, "secret.")
if ctx.Secrets != nil {
return ctx.Secrets[name]
}
return ""
}
return ""
}
})
}
@@ -0,0 +1,18 @@
package auth
import "testing"
func TestExpandPlaceholders(t *testing.T) {
body := "ping {{node.ip}} as {{node.username}} host={{cc.host}} env={{env.FOO}} secret={{secret.TOKEN}} unknown={{nope}}"
got := ExpandPlaceholders(body, PlaceholderContext{
Host: "web-1",
IP: "10.0.0.5",
Username: "deploy",
Env: map[string]string{"FOO": "bar"},
Secrets: map[string]string{},
})
want := "ping 10.0.0.5 as deploy host=web-1 env=bar secret= unknown="
if got != want {
t.Fatalf("got %q want %q", got, want)
}
}
+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)
}
}
+18 -2
View File
@@ -20,7 +20,10 @@ const (
sessionRotateAfter = time.Hour
)
var ErrSessionNotFound = errors.New("session not found")
var (
ErrSessionNotFound = errors.New("session not found")
ErrSessionIdle = errors.New("session idle")
)
// SessionManager persists sessions in sessions.enc and sets hardened cookies.
type SessionManager struct {
@@ -84,11 +87,15 @@ func (manager *SessionManager) CreateSession(
return record, nil
}
// LookupValidSession finds a non-expired, non-idle session and may rotate it.
// LookupValidSession finds a non-expired, non-idle session.
// When touchActivity is true, LastSeenAt is updated and the session may rotate.
// When touchActivity is false, the session is validated only (no LastSeenAt update,
// rotation, or sessions file write unless the session is being destroyed as idle/expired).
func (manager *SessionManager) LookupValidSession(
writer http.ResponseWriter,
request *http.Request,
security settings.SecuritySettings,
touchActivity bool,
) (settings.SessionRecord, error) {
cookie, err := request.Cookie(SessionCookieName)
if err != nil || strings.TrimSpace(cookie.Value) == "" {
@@ -108,6 +115,7 @@ func (manager *SessionManager) LookupValidSession(
lifetime := time.Duration(security.SessionLifetimeHours) * time.Hour
var found *settings.SessionRecord
idleMatched := false
remaining := make([]settings.SessionRecord, 0, len(store.Sessions))
for index := range store.Sessions {
session := store.Sessions[index]
@@ -116,6 +124,7 @@ func (manager *SessionManager) LookupValidSession(
}
if session.ID == cookie.Value {
if now.Sub(session.LastSeenAt) > idleLimit {
idleMatched = true
continue
}
copySession := session
@@ -129,9 +138,16 @@ func (manager *SessionManager) LookupValidSession(
store.Sessions = remaining
_ = settings.SaveSessions(manager.configDir, store, manager.key)
clearSessionCookie(writer)
if idleMatched {
return settings.SessionRecord{}, ErrSessionIdle
}
return settings.SessionRecord{}, ErrSessionNotFound
}
if !touchActivity {
return *found, nil
}
shouldRotate := now.Sub(found.CreatedAt) >= sessionRotateAfter ||
now.Sub(found.CreatedAt) >= lifetime/2
+174
View File
@@ -0,0 +1,174 @@
package auth
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
func TestLookupValidSessionReturnsIdle(t *testing.T) {
configDir := t.TempDir()
key := testSessionKey()
manager := NewSessionManager(configDir, key)
security := settings.DefaultSecuritySettings()
security.IdleTimeoutMinutes = 5
createRec := httptest.NewRecorder()
record, err := manager.CreateSession(createRec, "user-1", security)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
store, err := settings.LoadSessions(configDir, key)
if err != nil {
t.Fatalf("LoadSessions: %v", err)
}
if len(store.Sessions) != 1 {
t.Fatalf("expected 1 session, got %d", len(store.Sessions))
}
store.Sessions[0].LastSeenAt = time.Now().UTC().Add(-6 * time.Minute)
if err := settings.SaveSessions(configDir, store, key); err != nil {
t.Fatalf("SaveSessions: %v", err)
}
request := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
request.AddCookie(&http.Cookie{Name: SessionCookieName, Value: record.ID})
lookupRec := httptest.NewRecorder()
_, err = manager.LookupValidSession(lookupRec, request, security, true)
if !errors.Is(err, ErrSessionIdle) {
t.Fatalf("expected ErrSessionIdle, got %v", err)
}
store, err = settings.LoadSessionsOrEmpty(configDir, key)
if err != nil {
t.Fatalf("LoadSessionsOrEmpty: %v", err)
}
if len(store.Sessions) != 0 {
t.Fatalf("expected idle session removed, got %#v", store.Sessions)
}
}
func TestLookupValidSessionMissingCookie(t *testing.T) {
configDir := t.TempDir()
manager := NewSessionManager(configDir, testSessionKey())
security := settings.DefaultSecuritySettings()
request := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
lookupRec := httptest.NewRecorder()
_, err := manager.LookupValidSession(lookupRec, request, security, true)
if !errors.Is(err, ErrSessionNotFound) {
t.Fatalf("expected ErrSessionNotFound, got %v", err)
}
}
func TestLookupValidSessionUnknownCookie(t *testing.T) {
configDir := t.TempDir()
manager := NewSessionManager(configDir, testSessionKey())
security := settings.DefaultSecuritySettings()
request := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
request.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "unknown-session"})
lookupRec := httptest.NewRecorder()
_, err := manager.LookupValidSession(lookupRec, request, security, true)
if !errors.Is(err, ErrSessionNotFound) {
t.Fatalf("expected ErrSessionNotFound, got %v", err)
}
}
func TestLookupValidSessionWithoutTouchLeavesLastSeenAt(t *testing.T) {
configDir := t.TempDir()
key := testSessionKey()
manager := NewSessionManager(configDir, key)
security := settings.DefaultSecuritySettings()
security.IdleTimeoutMinutes = 30
createRec := httptest.NewRecorder()
record, err := manager.CreateSession(createRec, "user-1", security)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
store, err := settings.LoadSessions(configDir, key)
if err != nil {
t.Fatalf("LoadSessions: %v", err)
}
originalLastSeen := time.Now().UTC().Add(-10 * time.Minute).Truncate(time.Second)
store.Sessions[0].LastSeenAt = originalLastSeen
if err := settings.SaveSessions(configDir, store, key); err != nil {
t.Fatalf("SaveSessions: %v", err)
}
request := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
request.AddCookie(&http.Cookie{Name: SessionCookieName, Value: record.ID})
lookupRec := httptest.NewRecorder()
got, err := manager.LookupValidSession(lookupRec, request, security, false)
if err != nil {
t.Fatalf("LookupValidSession: %v", err)
}
if got.ID != record.ID {
t.Fatalf("expected session id %q, got %q", record.ID, got.ID)
}
store, err = settings.LoadSessions(configDir, key)
if err != nil {
t.Fatalf("LoadSessions after lookup: %v", err)
}
if len(store.Sessions) != 1 {
t.Fatalf("expected 1 session, got %d", len(store.Sessions))
}
if !store.Sessions[0].LastSeenAt.Equal(originalLastSeen) {
t.Fatalf(
"expected LastSeenAt %v unchanged, got %v",
originalLastSeen,
store.Sessions[0].LastSeenAt,
)
}
}
func TestLookupValidSessionIdleWithoutTouchStillIdle(t *testing.T) {
configDir := t.TempDir()
key := testSessionKey()
manager := NewSessionManager(configDir, key)
security := settings.DefaultSecuritySettings()
security.IdleTimeoutMinutes = 5
createRec := httptest.NewRecorder()
record, err := manager.CreateSession(createRec, "user-1", security)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
store, err := settings.LoadSessions(configDir, key)
if err != nil {
t.Fatalf("LoadSessions: %v", err)
}
store.Sessions[0].LastSeenAt = time.Now().UTC().Add(-6 * time.Minute)
if err := settings.SaveSessions(configDir, store, key); err != nil {
t.Fatalf("SaveSessions: %v", err)
}
request := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
request.AddCookie(&http.Cookie{Name: SessionCookieName, Value: record.ID})
lookupRec := httptest.NewRecorder()
_, err = manager.LookupValidSession(lookupRec, request, security, false)
if !errors.Is(err, ErrSessionIdle) {
t.Fatalf("expected ErrSessionIdle, got %v", err)
}
}
func testSessionKey() []byte {
key := make([]byte, 32)
for index := range key {
key[index] = byte(index + 3)
}
return key
}
+235
View File
@@ -0,0 +1,235 @@
package auth
import (
"bytes"
"fmt"
"net"
"strings"
"time"
"golang.org/x/crypto/ssh"
)
const defaultSSHRunTimeout = 5 * time.Minute
// SSHRunResult captures stdout, stderr, and exit status from a remote command.
type SSHRunResult struct {
Stdout string
Stderr string
ExitCode int
}
// RunSSHShell runs a single shell command on the remote host over SSH.
func RunSSHShell(
hostIP string,
username string,
privateKeyPEM string,
passphrase string,
command string,
env map[string]string,
timeout time.Duration,
) (SSHRunResult, error) {
if strings.TrimSpace(command) == "" {
return SSHRunResult{}, fmt.Errorf("command is required")
}
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,
privateKeyPEM string,
passphrase string,
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")
}
hostIP = strings.TrimSpace(hostIP)
username = strings.TrimSpace(username)
if hostIP == "" {
return SSHRunResult{}, fmt.Errorf("host IP is required")
}
if username == "" {
return SSHRunResult{}, fmt.Errorf("username is required")
}
if timeout <= 0 {
timeout = defaultSSHRunTimeout
}
signer, err := parseSigner(privateKeyPEM, passphrase)
if err != nil {
return SSHRunResult{}, err
}
config := &ssh.ClientConfig{
User: username,
Auth: []ssh.AuthMethod{
ssh.PublicKeys(signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: sshDialTimeout,
}
address := net.JoinHostPort(hostIP, defaultSSHPort)
client, err := ssh.Dial("tcp", address, config)
if err != nil {
return SSHRunResult{}, fmt.Errorf("ssh dial %s: %w", address, err)
}
defer client.Close()
session, err := client.NewSession()
if err != nil {
return SSHRunResult{}, fmt.Errorf("ssh session: %w", err)
}
defer session.Close()
var stdoutBuf bytes.Buffer
var stderrBuf bytes.Buffer
session.Stdout = &stdoutBuf
session.Stderr = &stderrBuf
session.Stdin = strings.NewReader(scriptBody)
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)
}()
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case err := <-done:
return finishSSHResult(stdoutBuf.String(), stderrBuf.String(), err)
case <-timer.C:
_ = session.Close()
return SSHRunResult{
Stdout: stdoutBuf.String(),
Stderr: stderrBuf.String(),
ExitCode: -1,
}, fmt.Errorf("ssh command timed out after %s", timeout)
}
}
func runSSHSession(
hostIP string,
username string,
privateKeyPEM string,
passphrase string,
command string,
timeout time.Duration,
) (SSHRunResult, error) {
hostIP = strings.TrimSpace(hostIP)
username = strings.TrimSpace(username)
if hostIP == "" {
return SSHRunResult{}, fmt.Errorf("host IP is required")
}
if username == "" {
return SSHRunResult{}, fmt.Errorf("username is required")
}
if timeout <= 0 {
timeout = defaultSSHRunTimeout
}
signer, err := parseSigner(privateKeyPEM, passphrase)
if err != nil {
return SSHRunResult{}, err
}
config := &ssh.ClientConfig{
User: username,
Auth: []ssh.AuthMethod{
ssh.PublicKeys(signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: sshDialTimeout,
}
address := net.JoinHostPort(hostIP, defaultSSHPort)
client, err := ssh.Dial("tcp", address, config)
if err != nil {
return SSHRunResult{}, fmt.Errorf("ssh dial %s: %w", address, err)
}
defer client.Close()
session, err := client.NewSession()
if err != nil {
return SSHRunResult{}, fmt.Errorf("ssh session: %w", err)
}
defer session.Close()
var stdoutBuf bytes.Buffer
var stderrBuf bytes.Buffer
session.Stdout = &stdoutBuf
session.Stderr = &stderrBuf
done := make(chan error, 1)
go func() {
done <- session.Run(command)
}()
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case err := <-done:
return finishSSHResult(stdoutBuf.String(), stderrBuf.String(), err)
case <-timer.C:
_ = session.Close()
return SSHRunResult{
Stdout: stdoutBuf.String(),
Stderr: stderrBuf.String(),
ExitCode: -1,
}, fmt.Errorf("ssh command timed out after %s", timeout)
}
}
func finishSSHResult(stdout string, stderr string, err error) (SSHRunResult, error) {
result := SSHRunResult{
Stdout: stdout,
Stderr: stderr,
ExitCode: 0,
}
if err == nil {
return result, nil
}
if exitErr, ok := err.(*ssh.ExitError); ok {
result.ExitCode = exitErr.ExitStatus()
return result, nil
}
result.ExitCode = -1
return result, err
}
// 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
}
var builder strings.Builder
for name, value := range env {
builder.WriteString("export ")
builder.WriteString(shellQuote(name))
builder.WriteString("=")
builder.WriteString(shellQuote(value))
builder.WriteString("; ")
}
builder.WriteString(command)
return builder.String()
}
func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'"
}
+286
View File
@@ -0,0 +1,286 @@
package health
import (
"fmt"
"sync"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
const schedulerTickInterval = 15 * time.Second
// Result is the outcome of a node health check.
type Result struct {
PingOK bool
SSHOK bool
OK bool
Message string
CheckedAt time.Time
}
// Checker runs and schedules per-node health checks.
type Checker struct {
ConfigDir string
Key []byte
mu sync.Mutex
running map[string]struct{}
stopCh chan struct{}
stopped chan struct{}
tickerOn bool
// Optional overrides for tests.
PingFn func(hostIP string) auth.PingResult
SSHFn func(hostIP string, username string, privateKeyPEM string, passphrase string) error
NowFn func() time.Time
}
// NewChecker creates a health checker for the given config directory and key.
func NewChecker(configDir string, key []byte) *Checker {
return &Checker{
ConfigDir: configDir,
Key: key,
running: map[string]struct{}{},
stopCh: make(chan struct{}),
stopped: make(chan struct{}),
}
}
// StartScheduler begins the background health-check ticker.
func (checker *Checker) StartScheduler() {
checker.mu.Lock()
if checker.tickerOn {
checker.mu.Unlock()
return
}
checker.tickerOn = true
checker.mu.Unlock()
go func() {
defer close(checker.stopped)
ticker := time.NewTicker(schedulerTickInterval)
defer ticker.Stop()
for {
select {
case <-checker.stopCh:
return
case <-ticker.C:
checker.tickDueChecks()
}
}
}()
}
// StopScheduler stops the background ticker and waits for it to exit.
func (checker *Checker) StopScheduler() {
checker.mu.Lock()
if !checker.tickerOn {
checker.mu.Unlock()
return
}
checker.tickerOn = false
checker.mu.Unlock()
close(checker.stopCh)
<-checker.stopped
}
func (checker *Checker) tryLockNode(nodeID string) bool {
checker.mu.Lock()
defer checker.mu.Unlock()
if _, ok := checker.running[nodeID]; ok {
return false
}
checker.running[nodeID] = struct{}{}
return true
}
func (checker *Checker) unlockNode(nodeID string) {
checker.mu.Lock()
defer checker.mu.Unlock()
delete(checker.running, nodeID)
}
func (checker *Checker) now() time.Time {
if checker.NowFn != nil {
return checker.NowFn()
}
return time.Now().UTC()
}
func (checker *Checker) tickDueChecks() {
if len(checker.Key) == 0 {
return
}
store, err := settings.LoadNodesOrEmpty(checker.ConfigDir)
if err != nil {
return
}
now := checker.now()
for _, node := range store.Nodes {
if !IsHealthCheckDue(node, now) {
continue
}
nodeID := node.ID
go func() {
_, _ = checker.CheckNodeByID(nodeID, "scheduler")
}()
}
}
// IsHealthCheckDue reports whether a node should be probed now.
func IsHealthCheckDue(node settings.Node, now time.Time) bool {
if node.HealthCheckIntervalSeconds <= 0 {
return false
}
if node.HealthLastCheckedAt == nil {
return true
}
elapsed := now.Sub(node.HealthLastCheckedAt.UTC())
return elapsed >= time.Duration(node.HealthCheckIntervalSeconds)*time.Second
}
// CheckNodeByID loads credentials, runs ping + SSH, and persists the result.
func (checker *Checker) CheckNodeByID(nodeID string, actor string) (settings.Node, error) {
if !checker.tryLockNode(nodeID) {
store, err := settings.LoadNodesOrEmpty(checker.ConfigDir)
if err != nil {
return settings.Node{}, err
}
for _, node := range store.Nodes {
if node.ID == nodeID {
return node, fmt.Errorf("health check already running for node")
}
}
return settings.Node{}, fmt.Errorf("node not found")
}
defer checker.unlockNode(nodeID)
if len(checker.Key) == 0 {
return settings.Node{}, fmt.Errorf("%s is not set", settings.ConfigKeyEnvVar)
}
store, err := settings.LoadNodesOrEmpty(checker.ConfigDir)
if err != nil {
return settings.Node{}, err
}
nodeIndex := -1
var node settings.Node
for index, candidate := range store.Nodes {
if candidate.ID != nodeID {
continue
}
node = candidate
nodeIndex = index
break
}
if nodeIndex < 0 {
return settings.Node{}, fmt.Errorf("node not found")
}
keyStore, err := settings.LoadNodeKeysOrEmpty(checker.ConfigDir, checker.Key)
if err != nil {
return settings.Node{}, err
}
var keyEntry settings.NodeKeyEntry
keyFound := false
for _, entry := range keyStore.Keys {
if entry.NodeID == node.ID {
keyEntry = entry
keyFound = true
break
}
}
if !keyFound {
return settings.Node{}, fmt.Errorf("private key not found for node")
}
result := checker.runChecks(node, keyEntry)
updated := applyHealthResult(node, result)
store.Nodes[nodeIndex] = updated
if err := settings.SaveNodes(checker.ConfigDir, store); err != nil {
return settings.Node{}, err
}
_ = appendHealthAudit(checker.ConfigDir, actor, updated, result.Message)
return updated, nil
}
func (checker *Checker) runChecks(node settings.Node, keyEntry settings.NodeKeyEntry) Result {
checkedAt := checker.now()
pingFn := checker.PingFn
if pingFn == nil {
pingFn = auth.PingHost
}
sshFn := checker.SSHFn
if sshFn == nil {
sshFn = auth.TestSSHConnection
}
pingResult := pingFn(node.HostIP)
sshErr := sshFn(node.HostIP, node.Username, keyEntry.PrivateKey, keyEntry.Passphrase)
sshOK := sshErr == nil
message := buildHealthMessage(pingResult, sshOK, sshErr)
return Result{
PingOK: pingResult.OK,
SSHOK: sshOK,
OK: pingResult.OK && sshOK,
Message: message,
CheckedAt: checkedAt,
}
}
func buildHealthMessage(pingResult auth.PingResult, sshOK bool, sshErr error) string {
var parts []string
if pingResult.OK {
parts = append(parts, fmt.Sprintf("ping ok (%s)", pingResult.Method))
} else if pingResult.Error != nil {
parts = append(parts, fmt.Sprintf("ping failed: %v", pingResult.Error))
} else {
parts = append(parts, "ping failed")
}
if sshOK {
parts = append(parts, "ssh ok")
} else if sshErr != nil {
parts = append(parts, fmt.Sprintf("ssh failed: %v", sshErr))
} else {
parts = append(parts, "ssh failed")
}
return fmt.Sprintf("%s; %s", parts[0], parts[1])
}
func applyHealthResult(node settings.Node, result Result) settings.Node {
checkedAt := result.CheckedAt
pingOK := result.PingOK
sshOK := result.SSHOK
healthOK := result.OK
node.HealthLastCheckedAt = &checkedAt
node.HealthPingOK = &pingOK
node.HealthSSHOK = &sshOK
node.HealthOK = &healthOK
node.HealthMessage = result.Message
return node
}
func appendHealthAudit(configDir string, actor string, node settings.Node, detail string) error {
eventID, err := auth.NewUUID()
if err != nil {
eventID = fmt.Sprintf("health-%d", time.Now().UnixNano())
}
return settings.AppendNodeAuditEvent(configDir, settings.NodeAuditEvent{
ID: eventID,
At: time.Now().UTC(),
Action: settings.NodeAuditActionHealthCheck,
Actor: actor,
NodeID: node.ID,
NodeName: node.Name,
NodeKind: node.Kind,
Detail: detail,
})
}
+164
View File
@@ -0,0 +1,164 @@
package health
import (
"errors"
"testing"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
func TestIsHealthCheckDue(t *testing.T) {
now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC)
t.Run("disabled", func(t *testing.T) {
if IsHealthCheckDue(settings.Node{HealthCheckIntervalSeconds: 0}, now) {
t.Fatal("expected not due")
}
})
t.Run("never checked", func(t *testing.T) {
if !IsHealthCheckDue(settings.Node{HealthCheckIntervalSeconds: 60}, now) {
t.Fatal("expected due")
}
})
t.Run("interval elapsed", func(t *testing.T) {
checked := now.Add(-61 * time.Second)
node := settings.Node{
HealthCheckIntervalSeconds: 60,
HealthLastCheckedAt: &checked,
}
if !IsHealthCheckDue(node, now) {
t.Fatal("expected due")
}
})
t.Run("interval not elapsed", func(t *testing.T) {
checked := now.Add(-30 * time.Second)
node := settings.Node{
HealthCheckIntervalSeconds: 60,
HealthLastCheckedAt: &checked,
}
if IsHealthCheckDue(node, now) {
t.Fatal("expected not due")
}
})
}
func TestCheckNodeByIDPersistsResult(t *testing.T) {
dir := t.TempDir()
keyBytes := make([]byte, 32)
for index := range keyBytes {
keyBytes[index] = byte(index + 1)
}
nodeID := "11111111-2222-4333-8444-555555555555"
store := settings.NodeStore{
Nodes: []settings.Node{{
ID: nodeID,
Kind: settings.NodeKindContainer,
Name: "ct-1",
HostIP: "10.9.9.9",
Username: "clustercanvas",
GroupName: "Administrators",
KeyAlgo: "ed25519",
CreatedAt: time.Now().UTC(),
}},
}
if err := settings.SaveNodes(dir, store); err != nil {
t.Fatalf("SaveNodes: %v", err)
}
if err := settings.SaveNodeKeys(dir, settings.NodeKeyStore{
Keys: []settings.NodeKeyEntry{{
NodeID: nodeID,
PrivateKey: "dummy-key",
Algorithm: "ed25519",
}},
}, keyBytes); err != nil {
t.Fatalf("SaveNodeKeys: %v", err)
}
checker := NewChecker(dir, keyBytes)
checker.NowFn = func() time.Time {
return time.Date(2026, 7, 19, 15, 0, 0, 0, time.UTC)
}
checker.PingFn = func(hostIP string) auth.PingResult {
return auth.PingResult{OK: true, Method: auth.PingMethodTCP}
}
checker.SSHFn = func(hostIP string, username string, privateKeyPEM string, passphrase string) error {
return nil
}
updated, err := checker.CheckNodeByID(nodeID, "tester")
if err != nil {
t.Fatalf("CheckNodeByID: %v", err)
}
if updated.HealthOK == nil || !*updated.HealthOK {
t.Fatalf("health_ok = %#v", updated.HealthOK)
}
if updated.HealthPingOK == nil || !*updated.HealthPingOK {
t.Fatal("expected ping ok")
}
if updated.HealthSSHOK == nil || !*updated.HealthSSHOK {
t.Fatal("expected ssh ok")
}
if updated.HealthMessage == "" {
t.Fatal("expected message")
}
loaded, err := settings.LoadNodes(dir)
if err != nil {
t.Fatalf("LoadNodes: %v", err)
}
if loaded.Nodes[0].HealthOK == nil || !*loaded.Nodes[0].HealthOK {
t.Fatal("persisted health_ok missing")
}
}
func TestCheckNodeByIDRecordsFailure(t *testing.T) {
dir := t.TempDir()
keyBytes := make([]byte, 32)
for index := range keyBytes {
keyBytes[index] = byte(index + 3)
}
nodeID := "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
if err := settings.SaveNodes(dir, settings.NodeStore{
Nodes: []settings.Node{{
ID: nodeID,
Kind: settings.NodeKindVM,
Name: "vm-1",
HostIP: "10.8.8.8",
Username: "clustercanvas",
}},
}); err != nil {
t.Fatalf("SaveNodes: %v", err)
}
if err := settings.SaveNodeKeys(dir, settings.NodeKeyStore{
Keys: []settings.NodeKeyEntry{{
NodeID: nodeID,
PrivateKey: "dummy-key",
Algorithm: "ed25519",
}},
}, keyBytes); err != nil {
t.Fatalf("SaveNodeKeys: %v", err)
}
checker := NewChecker(dir, keyBytes)
checker.PingFn = func(hostIP string) auth.PingResult {
return auth.PingResult{OK: false, Method: auth.PingMethodTCP, Error: errors.New("unreachable")}
}
checker.SSHFn = func(hostIP string, username string, privateKeyPEM string, passphrase string) error {
return errors.New("ssh dial failed")
}
updated, err := checker.CheckNodeByID(nodeID, "tester")
if err != nil {
t.Fatalf("CheckNodeByID: %v", err)
}
if updated.HealthOK == nil || *updated.HealthOK {
t.Fatalf("expected health failure, got %#v", updated.HealthOK)
}
}
+330
View File
@@ -0,0 +1,330 @@
package runner
import (
"errors"
"fmt"
"strconv"
"strings"
"unicode"
)
// ValidateCondition checks that expr is empty or a supported comparison.
func ValidateCondition(expr string) error {
trimmed := strings.TrimSpace(expr)
if trimmed == "" {
return nil
}
_, err := parseCondition(trimmed)
return err
}
const skipDetailMaxValueLen = 80
// EvaluateCondition evaluates a run-if expression against the current var map.
// Empty expr is always true. Eval failures return false with an error.
// On success, detail is a compact comparison (e.g. "$UpdateCount=3 >= 1").
// On a false result, detail is a human-readable skip explanation.
func EvaluateCondition(expr string, vars map[string]string) (ok bool, detail string, err error) {
trimmed := strings.TrimSpace(expr)
if trimmed == "" {
return true, "", nil
}
parsed, err := parseCondition(trimmed)
if err != nil {
return false, "", err
}
detail = formatEvaluatedCondition(parsed, vars)
leftValue := resolveOperand(parsed.left, vars)
rightValue := resolveOperand(parsed.right, vars)
ok, err = compareOperands(leftValue, parsed.op, rightValue)
if err != nil {
return false, detail, err
}
if !ok {
detail = formatSkipDetail(parsed, vars)
}
return ok, detail, nil
}
func formatEvaluatedCondition(parsed parsedCondition, vars map[string]string) string {
return fmt.Sprintf("%s %s %s",
formatOperandForDisplay(parsed.left, vars),
parsed.op,
formatOperandForDisplay(parsed.right, vars),
)
}
// formatSkipDetail builds a user-facing explanation when a run-if condition is false.
func formatSkipDetail(parsed parsedCondition, vars map[string]string) string {
rightDisplay := formatOperandValueForSkip(parsed.right, vars)
if parsed.left.kind == operandVar {
value := ""
if vars != nil {
value = vars[parsed.left.varName]
}
if strings.TrimSpace(value) == "" {
return fmt.Sprintf("$%s was unset, which does not meet run condition (%s %s)",
parsed.left.varName, parsed.op, rightDisplay)
}
return fmt.Sprintf("$%s was %s, which does not meet run condition (%s %s)",
parsed.left.varName, truncateForSkipDetail(value), parsed.op, rightDisplay)
}
return fmt.Sprintf("run condition not met (%s %s %s)",
formatOperandValueForSkip(parsed.left, vars),
parsed.op,
rightDisplay,
)
}
func formatOperandForDisplay(operand conditionOperand, vars map[string]string) string {
if operand.kind == operandVar {
value := ""
if vars != nil {
value = vars[operand.varName]
}
return fmt.Sprintf("$%s=%s", operand.varName, value)
}
return operand.literal
}
func formatOperandValueForSkip(operand conditionOperand, vars map[string]string) string {
if operand.kind == operandVar {
value := ""
if vars != nil {
value = vars[operand.varName]
}
if strings.TrimSpace(value) == "" {
return "unset"
}
return truncateForSkipDetail(value)
}
return operand.literal
}
func truncateForSkipDetail(value string) string {
trimmed := strings.TrimSpace(value)
runes := []rune(trimmed)
if len(runes) <= skipDetailMaxValueLen {
return trimmed
}
return string(runes[:skipDetailMaxValueLen]) + "…"
}
type conditionOp string
const (
opEQ conditionOp = "=="
opNE conditionOp = "!="
opGE conditionOp = ">="
opLE conditionOp = "<="
opGT conditionOp = ">"
opLT conditionOp = "<"
)
type conditionOperand struct {
kind operandKind
varName string
literal string
isNumeric bool
number float64
}
type operandKind int
const (
operandVar operandKind = iota
operandLiteral
)
type parsedCondition struct {
left conditionOperand
op conditionOp
right conditionOperand
}
func parseCondition(expr string) (parsedCondition, error) {
remaining := strings.TrimSpace(expr)
if strings.HasPrefix(strings.ToLower(remaining), "if ") || strings.EqualFold(remaining, "if") {
if len(remaining) < 3 {
return parsedCondition{}, errors.New("condition is empty after if")
}
remaining = strings.TrimSpace(remaining[2:])
}
if remaining == "" {
return parsedCondition{}, errors.New("condition is empty")
}
left, rest, err := parseOperand(remaining)
if err != nil {
return parsedCondition{}, err
}
rest = strings.TrimSpace(rest)
op, afterOp, err := parseOperator(rest)
if err != nil {
return parsedCondition{}, err
}
right, trailing, err := parseOperand(strings.TrimSpace(afterOp))
if err != nil {
return parsedCondition{}, err
}
if strings.TrimSpace(trailing) != "" {
return parsedCondition{}, fmt.Errorf("unexpected trailing input %q", strings.TrimSpace(trailing))
}
return parsedCondition{left: left, op: op, right: right}, nil
}
func parseOperator(input string) (conditionOp, string, error) {
operators := []conditionOp{opEQ, opNE, opGE, opLE, opGT, opLT}
for _, op := range operators {
prefix := string(op)
if strings.HasPrefix(input, prefix) {
return op, input[len(prefix):], nil
}
}
return "", "", errors.New("expected comparison operator (==, !=, >=, <=, >, <)")
}
func parseOperand(input string) (conditionOperand, string, error) {
input = strings.TrimSpace(input)
if input == "" {
return conditionOperand{}, "", errors.New("expected operand")
}
if input[0] == '$' {
name, rest, err := parseDollarName(input)
if err != nil {
return conditionOperand{}, "", err
}
if err := ValidateVariableName(name); err != nil {
return conditionOperand{}, "", err
}
return conditionOperand{kind: operandVar, varName: name}, rest, nil
}
if input[0] == '"' || input[0] == '\'' {
quote := input[0]
var builder strings.Builder
i := 1
for i < len(input) {
ch := input[i]
if ch == quote {
return conditionOperand{
kind: operandLiteral,
literal: builder.String(),
}, input[i+1:], nil
}
builder.WriteByte(ch)
i++
}
return conditionOperand{}, "", errors.New("unclosed string literal")
}
end := 0
for end < len(input) {
ch := rune(input[end])
if unicode.IsSpace(ch) {
break
}
// Stop before a comparison operator that is not part of a number sign.
if end > 0 && (ch == '=' || ch == '!' || ch == '>' || ch == '<') {
break
}
if end == 0 && (ch == '=' || ch == '!' || ch == '>' || ch == '<') {
break
}
end++
}
if end == 0 {
return conditionOperand{}, "", errors.New("expected operand")
}
token := input[:end]
if number, err := strconv.ParseFloat(token, 64); err == nil {
return conditionOperand{
kind: operandLiteral,
literal: token,
isNumeric: true,
number: number,
}, input[end:], nil
}
return conditionOperand{kind: operandLiteral, literal: token}, input[end:], nil
}
func parseDollarName(input string) (string, string, error) {
if !strings.HasPrefix(input, "$") {
return "", "", errors.New("expected $variable")
}
rest := input[1:]
if rest == "" {
return "", "", errors.New("expected variable name after $")
}
if rest[0] == '{' {
closeIdx := strings.IndexByte(rest, '}')
if closeIdx < 0 {
return "", "", errors.New("unclosed ${variable}")
}
name := rest[1:closeIdx]
return name, rest[closeIdx+1:], nil
}
end := 0
for end < len(rest) {
ch := rest[end]
if (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '_' {
end++
continue
}
break
}
if end == 0 {
return "", "", errors.New("expected variable name after $")
}
return rest[:end], rest[end:], nil
}
func resolveOperand(operand conditionOperand, vars map[string]string) conditionOperand {
if operand.kind != operandVar {
return operand
}
value := ""
if vars != nil {
value = vars[operand.varName]
}
resolved := conditionOperand{kind: operandLiteral, literal: value}
if number, err := strconv.ParseFloat(strings.TrimSpace(value), 64); err == nil && strings.TrimSpace(value) != "" {
resolved.isNumeric = true
resolved.number = number
}
return resolved
}
func compareOperands(left conditionOperand, op conditionOp, right conditionOperand) (bool, error) {
if left.isNumeric && right.isNumeric {
switch op {
case opEQ:
return left.number == right.number, nil
case opNE:
return left.number != right.number, nil
case opGE:
return left.number >= right.number, nil
case opLE:
return left.number <= right.number, nil
case opGT:
return left.number > right.number, nil
case opLT:
return left.number < right.number, nil
}
}
// Numeric ops with a non-numeric side evaluate to false (e.g. unset >= 1).
if op == opGE || op == opLE || op == opGT || op == opLT {
return false, nil
}
switch op {
case opEQ:
return left.literal == right.literal, nil
case opNE:
return left.literal != right.literal, nil
default:
return false, fmt.Errorf("unsupported operator %q", op)
}
}
+116
View File
@@ -0,0 +1,116 @@
package runner
import (
"strings"
"testing"
)
func TestValidateCondition(t *testing.T) {
if err := ValidateCondition(""); err != nil {
t.Fatalf("empty: %v", err)
}
if err := ValidateCondition("if $UpdateCount >= 1"); err != nil {
t.Fatalf("valid: %v", err)
}
if err := ValidateCondition("$X == \"ok\""); err != nil {
t.Fatalf("string: %v", err)
}
if err := ValidateCondition("not a condition"); err == nil {
t.Fatal("expected invalid condition error")
}
}
func TestEvaluateCondition(t *testing.T) {
vars := map[string]string{"UpdateCount": "3", "Status": "ready"}
ok, detail, err := EvaluateCondition("if $UpdateCount >= 1", vars)
if err != nil || !ok {
t.Fatalf("expected true, got %v %v", ok, err)
}
if detail != "$UpdateCount=3 >= 1" {
t.Fatalf("detail = %q", detail)
}
ok, detail, err = EvaluateCondition("$UpdateCount >= 10", vars)
if err != nil || ok {
t.Fatalf("expected false, got %v %v", ok, err)
}
wantFail := "$UpdateCount was 3, which does not meet run condition (>= 10)"
if detail != wantFail {
t.Fatalf("detail = %q, want %q", detail, wantFail)
}
ok, detail, err = EvaluateCondition("$Missing >= 1", vars)
if err != nil || ok {
t.Fatalf("unset numeric compare should be false, got %v %v", ok, err)
}
wantUnset := "$Missing was unset, which does not meet run condition (>= 1)"
if detail != wantUnset {
t.Fatalf("detail = %q, want %q", detail, wantUnset)
}
ok, detail, err = EvaluateCondition("$Status == ready", vars)
if err != nil || !ok {
t.Fatalf("string eq expected true, got %v %v", ok, err)
}
if detail != "$Status=ready == ready" {
t.Fatalf("detail = %q", detail)
}
ok, detail, err = EvaluateCondition("", vars)
if err != nil || !ok {
t.Fatalf("empty should be true, got %v %v", ok, err)
}
if detail != "" {
t.Fatalf("empty detail = %q", detail)
}
ok, detail, err = EvaluateCondition("$Status != 'busy'", vars)
if err != nil || !ok {
t.Fatalf("quoted ne expected true, got %v %v", ok, err)
}
if detail != "$Status=ready != busy" {
t.Fatalf("detail = %q", detail)
}
}
func TestEvaluateConditionDetailUnset(t *testing.T) {
ok, detail, err := EvaluateCondition("if $UpdateCount >= 10", map[string]string{})
if err != nil || ok {
t.Fatalf("expected false, got %v %v", ok, err)
}
want := "$UpdateCount was unset, which does not meet run condition (>= 10)"
if detail != want {
t.Fatalf("detail = %q, want %q", detail, want)
}
}
func TestEvaluateConditionSkipDetailZero(t *testing.T) {
ok, detail, err := EvaluateCondition("if $UpdateCount >= 1", map[string]string{"UpdateCount": "0"})
if err != nil || ok {
t.Fatalf("expected false, got %v %v", ok, err)
}
want := "$UpdateCount was 0, which does not meet run condition (>= 1)"
if detail != want {
t.Fatalf("detail = %q, want %q", detail, want)
}
}
func TestEvaluateConditionSkipDetailTruncatesLongValue(t *testing.T) {
longValue := strings.Repeat("x", skipDetailMaxValueLen+20)
ok, detail, err := EvaluateCondition("if $UpdateCount >= 1", map[string]string{"UpdateCount": longValue})
if err != nil || ok {
t.Fatalf("expected false, got %v %v", ok, err)
}
truncated := truncateForSkipDetail(longValue)
want := "$UpdateCount was " + truncated + ", which does not meet run condition (>= 1)"
if detail != want {
t.Fatalf("detail = %q, want %q", detail, want)
}
if !strings.HasSuffix(truncated, "…") {
t.Fatalf("expected truncation ellipsis in %q", truncated)
}
if len([]rune(truncated)) != skipDetailMaxValueLen+1 {
t.Fatalf("truncated length = %d", len([]rune(truncated)))
}
}
+449
View File
@@ -0,0 +1,449 @@
package runner
import (
"fmt"
"strings"
"sync"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
const defaultRunTimeout = 5 * time.Minute
// Executor runs action groups and items against nodes over SSH.
type Executor struct {
ConfigDir string
Key []byte
mu sync.Mutex
running map[string]struct{} // groupID -> running
stopCh chan struct{}
stopped chan struct{}
tickerOn bool
}
// NewExecutor creates an Executor for the given config directory and encryption key.
func NewExecutor(configDir string, key []byte) *Executor {
return &Executor{
ConfigDir: configDir,
Key: key,
running: map[string]struct{}{},
stopCh: make(chan struct{}),
stopped: make(chan struct{}),
}
}
// StartScheduler begins the background schedule ticker (30s).
func (executor *Executor) StartScheduler() {
executor.mu.Lock()
if executor.tickerOn {
executor.mu.Unlock()
return
}
executor.tickerOn = true
executor.mu.Unlock()
go func() {
defer close(executor.stopped)
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-executor.stopCh:
return
case <-ticker.C:
executor.tickSchedules()
}
}
}()
}
// StopScheduler stops the background ticker and waits for it to exit.
func (executor *Executor) StopScheduler() {
executor.mu.Lock()
if !executor.tickerOn {
executor.mu.Unlock()
return
}
executor.tickerOn = false
executor.mu.Unlock()
close(executor.stopCh)
<-executor.stopped
}
func (executor *Executor) tryLockGroup(groupID string) bool {
executor.mu.Lock()
defer executor.mu.Unlock()
if _, ok := executor.running[groupID]; ok {
return false
}
executor.running[groupID] = struct{}{}
return true
}
func (executor *Executor) unlockGroup(groupID string) {
executor.mu.Lock()
defer executor.mu.Unlock()
delete(executor.running, groupID)
}
func (executor *Executor) tickSchedules() {
store, err := settings.LoadNodeActionGroupsOrEmpty(executor.ConfigDir)
if err != nil {
return
}
now := time.Now()
for _, group := range store.Groups {
if !settings.IsScheduleDue(group, now) {
continue
}
groupCopy := group
go func() {
_, _ = executor.RunGroup(groupCopy, settings.ActionRunTriggerSchedule, "scheduler")
}()
}
}
// ResolvedAction is the effective action definition after library resolution.
type ResolvedAction struct {
Name string
Description string
Kind settings.ActionKind
Body string
Env []settings.ActionEnvVar
RequiresSudo bool
Source settings.ActionItemSource
}
// ResolveItem resolves a library or local item to an executable action.
func ResolveItem(item settings.NodeActionItem, library []settings.Action) (ResolvedAction, error) {
switch item.Source {
case settings.ActionItemSourceLibrary:
if strings.TrimSpace(item.LibraryActionID) == "" {
return ResolvedAction{}, fmt.Errorf("library action id is required")
}
for _, action := range library {
if action.ID == item.LibraryActionID {
env := action.Env
if env == nil {
env = []settings.ActionEnvVar{}
}
return ResolvedAction{
Name: action.Name,
Description: action.Description,
Kind: action.Kind,
Body: action.Body,
Env: env,
RequiresSudo: action.RequiresSudo,
Source: settings.ActionItemSourceLibrary,
}, nil
}
}
return ResolvedAction{}, fmt.Errorf("library action %q not found", item.LibraryActionID)
case settings.ActionItemSourceLocal:
if strings.TrimSpace(item.Name) == "" {
return ResolvedAction{}, fmt.Errorf("local action name is required")
}
if strings.TrimSpace(item.Body) == "" {
return ResolvedAction{}, fmt.Errorf("local action body is required")
}
if item.Kind != settings.ActionKindShell && item.Kind != settings.ActionKindScript {
return ResolvedAction{}, fmt.Errorf("local action kind must be shell or script")
}
env := item.Env
if env == nil {
env = []settings.ActionEnvVar{}
}
return ResolvedAction{
Name: item.Name,
Description: item.Description,
Kind: item.Kind,
Body: item.Body,
Env: env,
RequiresSudo: item.RequiresSudo,
Source: settings.ActionItemSourceLocal,
}, nil
default:
return ResolvedAction{}, fmt.Errorf("unknown action item source %q", item.Source)
}
}
// WrapShellWithSudo prepends "sudo -n " when requiresSudo is true.
func WrapShellWithSudo(command string, requiresSudo bool) string {
if !requiresSudo {
return command
}
return "sudo -n " + command
}
// ScriptRemoteCommand is the remote argv used for script actions (body on stdin).
func ScriptRemoteCommand(requiresSudo bool) string {
if requiresSudo {
return "sudo -n bash -s"
}
return "bash -s"
}
// BuildRemoteCommand returns the final SSH exec string for an action (including env exports).
func BuildRemoteCommand(
kind settings.ActionKind,
body string,
requiresSudo bool,
env map[string]string,
) string {
var command string
switch kind {
case settings.ActionKindShell:
command = WrapShellWithSudo(body, requiresSudo)
case settings.ActionKindScript:
command = ScriptRemoteCommand(requiresSudo)
default:
command = body
}
return auth.PrependEnvExports(env, command)
}
// RunGroup executes all items in order, appends a log file entry, and updates LastRunAt.
func (executor *Executor) RunGroup(
group settings.NodeActionGroup,
trigger settings.ActionRunTrigger,
actor string,
) (settings.ActionGroupRunRecord, error) {
if !executor.tryLockGroup(group.ID) {
return settings.ActionGroupRunRecord{}, fmt.Errorf("action group is already running")
}
defer executor.unlockGroup(group.ID)
return executor.runItems(group, group.Items, trigger, actor)
}
// RunItem executes a single item (still logged under the parent group file).
func (executor *Executor) RunItem(
group settings.NodeActionGroup,
item settings.NodeActionItem,
trigger settings.ActionRunTrigger,
actor string,
) (settings.ActionGroupRunRecord, error) {
lockKey := group.ID + ":" + item.ID
if !executor.tryLockGroup(lockKey) {
return settings.ActionGroupRunRecord{}, fmt.Errorf("action is already running")
}
defer executor.unlockGroup(lockKey)
return executor.runItems(group, []settings.NodeActionItem{item}, trigger, actor)
}
func (executor *Executor) runItems(
group settings.NodeActionGroup,
items []settings.NodeActionItem,
trigger settings.ActionRunTrigger,
actor string,
) (settings.ActionGroupRunRecord, error) {
runID, err := auth.NewUUID()
if err != nil {
return settings.ActionGroupRunRecord{}, err
}
node, privateKey, passphrase, err := executor.loadNodeCredentials(group.NodeID)
if err != nil {
return settings.ActionGroupRunRecord{}, err
}
libraryStore, err := settings.LoadActionsOrSeed(executor.ConfigDir)
if err != nil {
return settings.ActionGroupRunRecord{}, err
}
startedAt := time.Now().UTC()
results := make([]settings.ActionItemRunResult, 0, len(items))
runVars := map[string]string{}
for _, item := range items {
itemStarted := time.Now().UTC()
resolved, resolveErr := ResolveItem(item, libraryStore.Actions)
if resolveErr != nil {
results = append(results, settings.ActionItemRunResult{
ItemID: item.ID,
ActionName: item.Name,
Source: string(item.Source),
ExitCode: -1,
Stdout: "",
Stderr: "",
Error: resolveErr.Error(),
StartedAt: itemStarted,
FinishedAt: time.Now().UTC(),
})
continue
}
runIf := strings.TrimSpace(item.RunIf)
if runIf != "" {
shouldRun, detail, condErr := EvaluateCondition(runIf, runVars)
if condErr != nil || !shouldRun {
skipReason := fmt.Sprintf("%s skipped: run condition was not met", resolved.Name)
if detail != "" {
skipReason = fmt.Sprintf("%s skipped: %s", resolved.Name, detail)
}
if condErr != nil {
skipReason = fmt.Sprintf("%s skipped because condition error: %s", resolved.Name, condErr.Error())
}
results = append(results, settings.ActionItemRunResult{
ItemID: item.ID,
ActionName: resolved.Name,
Source: string(resolved.Source),
ExitCode: 0,
Stdout: "",
Stderr: "",
Skipped: true,
SkipReason: skipReason,
StartedAt: itemStarted,
FinishedAt: time.Now().UTC(),
})
continue
}
}
rawEnv := make(map[string]string, len(resolved.Env))
for _, envVar := range resolved.Env {
rawEnv[envVar.Name] = envVar.Value
}
envMap := make(map[string]string, len(resolved.Env))
for _, envVar := range resolved.Env {
expandedValue := auth.ExpandPlaceholders(envVar.Value, auth.PlaceholderContext{
Host: node.Name,
IP: node.HostIP,
Username: node.Username,
Env: rawEnv,
Secrets: map[string]string{},
})
envMap[envVar.Name] = ExpandDollarVars(expandedValue, runVars)
}
expandedBody := auth.ExpandPlaceholders(resolved.Body, auth.PlaceholderContext{
Host: node.Name,
IP: node.HostIP,
Username: node.Username,
Env: envMap,
Secrets: map[string]string{},
})
expandedBody = ExpandDollarVars(expandedBody, runVars)
remoteCommand := BuildRemoteCommand(
resolved.Kind,
expandedBody,
resolved.RequiresSudo,
envMap,
)
var sshResult auth.SSHRunResult
var runErr error
switch resolved.Kind {
case settings.ActionKindShell:
sshResult, runErr = auth.RunSSHShell(
node.HostIP,
node.Username,
privateKey,
passphrase,
WrapShellWithSudo(expandedBody, resolved.RequiresSudo),
envMap,
defaultRunTimeout,
)
case settings.ActionKindScript:
sshResult, runErr = auth.RunSSHScript(
node.HostIP,
node.Username,
privateKey,
passphrase,
expandedBody,
envMap,
defaultRunTimeout,
resolved.RequiresSudo,
)
default:
runErr = fmt.Errorf("unsupported action kind %q", resolved.Kind)
}
itemResult := settings.ActionItemRunResult{
ItemID: item.ID,
ActionName: resolved.Name,
Source: string(resolved.Source),
Kind: resolved.Kind,
RemoteCommand: remoteCommand,
SSHUsername: node.Username,
ExitCode: sshResult.ExitCode,
Stdout: sshResult.Stdout,
Stderr: sshResult.Stderr,
StartedAt: itemStarted,
FinishedAt: time.Now().UTC(),
}
if runErr != nil {
itemResult.Error = runErr.Error()
if itemResult.ExitCode == 0 {
itemResult.ExitCode = -1
}
} else if setName := strings.TrimSpace(item.SetVariable); setName != "" {
captured := strings.TrimSpace(sshResult.Stdout)
runVars[setName] = captured
itemResult.SetVariable = setName
itemResult.VariableValue = captured
}
results = append(results, itemResult)
}
finishedAt := time.Now().UTC()
record := settings.ActionGroupRunRecord{
ID: runID,
NodeID: group.NodeID,
GroupID: group.ID,
GroupName: group.Name,
Trigger: trigger,
Actor: actor,
StartedAt: startedAt,
FinishedAt: finishedAt,
Actions: results,
}
if err := settings.AppendActionLogRun(executor.ConfigDir, record); err != nil {
return record, fmt.Errorf("append action log: %w", err)
}
if err := settings.UpdateGroupLastRunAt(executor.ConfigDir, group.ID, finishedAt); err != nil {
return record, fmt.Errorf("update last run: %w", err)
}
return record, nil
}
func (executor *Executor) loadNodeCredentials(nodeID string) (settings.Node, string, string, error) {
if len(executor.Key) == 0 {
return settings.Node{}, "", "", fmt.Errorf("%s is not set", settings.ConfigKeyEnvVar)
}
nodeStore, err := settings.LoadNodesOrEmpty(executor.ConfigDir)
if err != nil {
return settings.Node{}, "", "", err
}
var node settings.Node
found := false
for _, candidate := range nodeStore.Nodes {
if candidate.ID == nodeID {
node = candidate
found = true
break
}
}
if !found {
return settings.Node{}, "", "", fmt.Errorf("node not found")
}
keyStore, err := settings.LoadNodeKeysOrEmpty(executor.ConfigDir, executor.Key)
if err != nil {
return settings.Node{}, "", "", err
}
for _, entry := range keyStore.Keys {
if entry.NodeID == nodeID {
return node, entry.PrivateKey, entry.Passphrase, nil
}
}
return settings.Node{}, "", "", fmt.Errorf("node private key not found")
}
@@ -0,0 +1,99 @@
package runner
import (
"testing"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
func TestWrapShellWithSudo(t *testing.T) {
if got := WrapShellWithSudo("/usr/bin/systemctl restart myservice", false); got != "/usr/bin/systemctl restart myservice" {
t.Fatalf("without sudo = %q", got)
}
if got := WrapShellWithSudo("/usr/bin/systemctl restart myservice", true); got != "sudo -n /usr/bin/systemctl restart myservice" {
t.Fatalf("with sudo = %q", got)
}
}
func TestScriptRemoteCommand(t *testing.T) {
if got := ScriptRemoteCommand(false); got != "bash -s" {
t.Fatalf("without sudo = %q", got)
}
if got := ScriptRemoteCommand(true); got != "sudo -n bash -s" {
t.Fatalf("with sudo = %q", got)
}
}
func TestBuildRemoteCommand(t *testing.T) {
shellCmd := BuildRemoteCommand(settings.ActionKindShell, "apt update", true, nil)
if shellCmd != "sudo -n apt update" {
t.Fatalf("shell = %q", shellCmd)
}
scriptCmd := BuildRemoteCommand(settings.ActionKindScript, "apt update", true, nil)
if scriptCmd != "sudo -n bash -s" {
t.Fatalf("script = %q", scriptCmd)
}
withEnv := BuildRemoteCommand(
settings.ActionKindShell,
"apt update",
true,
map[string]string{"FOO": "bar"},
)
if withEnv != "export 'FOO'='bar'; sudo -n apt update" {
t.Fatalf("with env = %q", withEnv)
}
}
func TestResolveItemRequiresSudo(t *testing.T) {
library := []settings.Action{
{
ID: "lib-1",
Name: "Restart",
Kind: settings.ActionKindShell,
Body: "/usr/bin/systemctl restart myservice",
RequiresSudo: true,
Env: []settings.ActionEnvVar{},
},
}
resolved, err := ResolveItem(settings.NodeActionItem{
ID: "i1",
Source: settings.ActionItemSourceLibrary,
LibraryActionID: "lib-1",
}, library)
if err != nil {
t.Fatalf("resolve library: %v", err)
}
if !resolved.RequiresSudo || resolved.Body != "/usr/bin/systemctl restart myservice" {
t.Fatalf("library resolved = %#v", resolved)
}
local, err := ResolveItem(settings.NodeActionItem{
ID: "i2",
Source: settings.ActionItemSourceLocal,
Name: "Local restart",
Kind: settings.ActionKindShell,
Body: "/usr/bin/true",
RequiresSudo: true,
}, library)
if err != nil {
t.Fatalf("resolve local: %v", err)
}
if !local.RequiresSudo {
t.Fatalf("local resolved = %#v", local)
}
noSudo, err := ResolveItem(settings.NodeActionItem{
ID: "i3",
Source: settings.ActionItemSourceLocal,
Name: "Uptime",
Kind: settings.ActionKindShell,
Body: "uptime",
}, library)
if err != nil {
t.Fatalf("resolve no-sudo: %v", err)
}
if noSudo.RequiresSudo {
t.Fatalf("expected RequiresSudo false, got %#v", noSudo)
}
}
+53
View File
@@ -0,0 +1,53 @@
package runner
import (
"testing"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
func TestResolveItemLibraryAndLocal(t *testing.T) {
library := []settings.Action{
{
ID: settings.BuiltinActionUptimeID,
Name: "Uptime",
Kind: settings.ActionKindShell,
Body: "uptime",
Env: []settings.ActionEnvVar{},
},
}
resolved, err := ResolveItem(settings.NodeActionItem{
ID: "i1",
Source: settings.ActionItemSourceLibrary,
LibraryActionID: settings.BuiltinActionUptimeID,
}, library)
if err != nil {
t.Fatalf("ResolveItem library: %v", err)
}
if resolved.Name != "Uptime" || resolved.Body != "uptime" {
t.Fatalf("resolved = %#v", resolved)
}
local, err := ResolveItem(settings.NodeActionItem{
ID: "i2",
Source: settings.ActionItemSourceLocal,
Name: "Echo",
Kind: settings.ActionKindShell,
Body: "echo hi",
Env: []settings.ActionEnvVar{},
}, library)
if err != nil {
t.Fatalf("ResolveItem local: %v", err)
}
if local.Name != "Echo" {
t.Fatalf("local = %#v", local)
}
if _, err := ResolveItem(settings.NodeActionItem{
Source: settings.ActionItemSourceLibrary,
LibraryActionID: "missing",
}, library); err == nil {
t.Fatal("expected missing library action error")
}
}
@@ -0,0 +1,84 @@
package runner
import (
"strings"
"testing"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
func TestRunScopedVariableSequence(t *testing.T) {
vars := map[string]string{}
// Action 1 captures trimmed stdout into UpdateCount.
captured := strings.TrimSpace("3\n")
vars["UpdateCount"] = captured
if vars["UpdateCount"] != "3" {
t.Fatalf("captured = %q", vars["UpdateCount"])
}
// Action 2 condition passes and body expands.
ok, detail, err := EvaluateCondition("if $UpdateCount >= 1", vars)
if err != nil || !ok {
t.Fatalf("condition should pass: ok=%v err=%v", ok, err)
}
if detail != "$UpdateCount=3 >= 1" {
t.Fatalf("detail = %q", detail)
}
body := ExpandDollarVars("echo upgrades=$UpdateCount", vars)
if body != "echo upgrades=3" {
t.Fatalf("body = %q", body)
}
// Action 3 condition fails → skipped.
ok, detail, err = EvaluateCondition("if $UpdateCount >= 10", vars)
if err != nil || ok {
t.Fatalf("condition should fail: ok=%v err=%v", ok, err)
}
wantFail := "$UpdateCount was 3, which does not meet run condition (>= 10)"
if detail != wantFail {
t.Fatalf("detail = %q, want %q", detail, wantFail)
}
// A fresh run starts with an empty map (no leak).
nextRun := map[string]string{}
ok, detail, err = EvaluateCondition("if $UpdateCount >= 1", nextRun)
if err != nil || ok {
t.Fatalf("unset in new run should be false: ok=%v err=%v", ok, err)
}
wantUnset := "$UpdateCount was unset, which does not meet run condition (>= 1)"
if detail != wantUnset {
t.Fatalf("detail = %q, want %q", detail, wantUnset)
}
}
func TestSkipResultShape(t *testing.T) {
item := settings.NodeActionItem{
ID: "i1",
Name: "Upgrade",
RunIf: "if $UpdateCount >= 1",
}
vars := map[string]string{"UpdateCount": "0"}
shouldRun, detail, condErr := EvaluateCondition(item.RunIf, vars)
if condErr != nil {
t.Fatalf("eval: %v", condErr)
}
if shouldRun {
t.Fatal("expected skip")
}
skipReason := item.Name + " skipped: run condition was not met"
if detail != "" {
skipReason = item.Name + " skipped: " + detail
}
result := settings.ActionItemRunResult{
ItemID: item.ID,
ActionName: item.Name,
Skipped: true,
SkipReason: skipReason,
ExitCode: 0,
}
want := "Upgrade skipped: $UpdateCount was 0, which does not meet run condition (>= 1)"
if !result.Skipped || result.SkipReason != want {
t.Fatalf("result = %#v, want SkipReason %q", result, want)
}
}
+52
View File
@@ -0,0 +1,52 @@
package runner
import (
"errors"
"fmt"
"regexp"
"strings"
)
var (
variableNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
// Matches $Name or ${Name}; only known names in the vars map are replaced.
dollarVarPattern = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)`)
)
// ValidateVariableName checks that name is a valid run-scoped variable identifier.
func ValidateVariableName(name string) error {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
return errors.New("variable name is required")
}
if trimmed != name {
return errors.New("variable name must not have leading or trailing spaces")
}
if !variableNamePattern.MatchString(trimmed) {
return fmt.Errorf("invalid variable name %q", name)
}
return nil
}
// ExpandDollarVars replaces $Name and ${Name} when Name exists in vars.
// Unknown identifiers are left unchanged for the remote shell.
func ExpandDollarVars(text string, vars map[string]string) string {
if vars == nil || len(vars) == 0 || text == "" {
return text
}
return dollarVarPattern.ReplaceAllStringFunc(text, func(match string) string {
submatches := dollarVarPattern.FindStringSubmatch(match)
if len(submatches) < 3 {
return match
}
name := submatches[1]
if name == "" {
name = submatches[2]
}
value, ok := vars[name]
if !ok {
return match
}
return value
})
}
+33
View File
@@ -0,0 +1,33 @@
package runner
import "testing"
func TestValidateVariableName(t *testing.T) {
if err := ValidateVariableName("UpdateCount"); err != nil {
t.Fatalf("UpdateCount: %v", err)
}
if err := ValidateVariableName("_x"); err != nil {
t.Fatalf("_x: %v", err)
}
if err := ValidateVariableName(""); err == nil {
t.Fatal("expected empty name error")
}
if err := ValidateVariableName("1bad"); err == nil {
t.Fatal("expected invalid name error")
}
if err := ValidateVariableName("bad-name"); err == nil {
t.Fatal("expected invalid name error")
}
}
func TestExpandDollarVars(t *testing.T) {
vars := map[string]string{"UpdateCount": "3", "Name": "web"}
got := ExpandDollarVars("count=$UpdateCount host=${Name}-01 keep=$HOME", vars)
want := "count=3 host=web-01 keep=$HOME"
if got != want {
t.Fatalf("got %q want %q", got, want)
}
if ExpandDollarVars("$Missing", nil) != "$Missing" {
t.Fatal("nil vars should leave tokens unchanged")
}
}
+174
View File
@@ -0,0 +1,174 @@
package settings
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
// MaxActionLogRuns is the retention cap per action-group log file.
const MaxActionLogRuns = 200
// ActionLogDir returns action-logs/<nodeID> under the config dir.
func ActionLogDir(configDir string, nodeID string) string {
return filepath.Join(configDir, ActionLogsDirName, nodeID)
}
// ActionLogFilePath returns the JSON log path for a node + group name.
func ActionLogFilePath(configDir string, nodeID string, groupName string) string {
filename := DiskSafeFilename(groupName) + ".json"
return filepath.Join(ActionLogDir(configDir, nodeID), filename)
}
// AppendActionLogRun appends a run record to the group's log file and trims retention.
func AppendActionLogRun(configDir string, record ActionGroupRunRecord) error {
if strings.TrimSpace(record.NodeID) == "" {
return fmt.Errorf("node id is required")
}
if strings.TrimSpace(record.GroupName) == "" {
return fmt.Errorf("group name is required")
}
dir := ActionLogDir(configDir, record.NodeID)
if err := os.MkdirAll(dir, 0o750); err != nil {
return fmt.Errorf("create action log dir: %w", err)
}
path := ActionLogFilePath(configDir, record.NodeID, record.GroupName)
file, err := LoadActionLogFileOrEmpty(path, record.NodeID, record.GroupID, record.GroupName)
if err != nil {
return err
}
file.NodeID = record.NodeID
file.GroupID = record.GroupID
file.GroupName = record.GroupName
file.Runs = append(file.Runs, record)
if len(file.Runs) > MaxActionLogRuns {
file.Runs = file.Runs[len(file.Runs)-MaxActionLogRuns:]
}
return SaveActionLogFile(path, file)
}
// LoadActionLogFile reads a log JSON file from path.
func LoadActionLogFile(path string) (ActionLogFile, error) {
payload, err := os.ReadFile(path)
if err != nil {
return ActionLogFile{}, err
}
var file ActionLogFile
if err := json.Unmarshal(payload, &file); err != nil {
return ActionLogFile{}, fmt.Errorf("parse action log: %w", err)
}
if file.Runs == nil {
file.Runs = []ActionGroupRunRecord{}
}
return file, nil
}
// LoadActionLogFileOrEmpty returns an empty file when missing.
func LoadActionLogFileOrEmpty(
path string,
nodeID string,
groupID string,
groupName string,
) (ActionLogFile, error) {
file, err := LoadActionLogFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return ActionLogFile{
NodeID: nodeID,
GroupID: groupID,
GroupName: groupName,
Runs: []ActionGroupRunRecord{},
}, nil
}
return ActionLogFile{}, err
}
return file, nil
}
// SaveActionLogFile writes a log JSON file with mode 0640.
func SaveActionLogFile(path string, file ActionLogFile) error {
if file.Runs == nil {
file.Runs = []ActionGroupRunRecord{}
}
payload, err := json.MarshalIndent(file, "", " ")
if err != nil {
return fmt.Errorf("encode action log: %w", err)
}
payload = append(payload, '\n')
if err := os.WriteFile(path, payload, 0o640); err != nil {
return fmt.Errorf("write action log: %w", err)
}
return nil
}
// ListActionLogFiles lists JSON log files for a node.
func ListActionLogFiles(configDir string, nodeID string) ([]ActionLogFileInfo, error) {
dir := ActionLogDir(configDir, nodeID)
entries, err := os.ReadDir(dir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return []ActionLogFileInfo{}, nil
}
return nil, fmt.Errorf("list action logs: %w", err)
}
result := make([]ActionLogFileInfo, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasSuffix(name, ".json") {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
groupName := strings.TrimSuffix(name, ".json")
groupID := ""
file, loadErr := LoadActionLogFile(filepath.Join(dir, name))
if loadErr == nil {
if file.GroupName != "" {
groupName = file.GroupName
}
groupID = file.GroupID
}
result = append(result, ActionLogFileInfo{
Filename: name,
GroupName: groupName,
GroupID: groupID,
ModTime: info.ModTime().UTC(),
SizeBytes: info.Size(),
})
}
return result, nil
}
// ReadActionLogFileByName reads one log file for a node after validating the name.
func ReadActionLogFileByName(configDir string, nodeID string, filename string) (ActionLogFile, error) {
safeName := filepath.Base(strings.TrimSpace(filename))
if safeName == "." || safeName == ".." || safeName == "" {
return ActionLogFile{}, fmt.Errorf("invalid log filename")
}
if !strings.HasSuffix(safeName, ".json") {
return ActionLogFile{}, fmt.Errorf("invalid log filename")
}
if strings.Contains(safeName, "/") || strings.Contains(safeName, `\`) {
return ActionLogFile{}, fmt.Errorf("invalid log filename")
}
path := filepath.Join(ActionLogDir(configDir, nodeID), safeName)
cleaned := filepath.Clean(path)
if !strings.HasPrefix(cleaned, filepath.Clean(ActionLogDir(configDir, nodeID))+string(os.PathSeparator)) {
return ActionLogFile{}, fmt.Errorf("invalid log filename")
}
return LoadActionLogFile(cleaned)
}
+127
View File
@@ -0,0 +1,127 @@
package settings
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
)
// Stable IDs for seeded built-in actions.
const (
BuiltinActionUptimeID = "00000000-0000-4000-8000-000000000001"
BuiltinActionDiskUsageID = "00000000-0000-4000-8000-000000000002"
BuiltinActionSysInfoID = "00000000-0000-4000-8000-000000000003"
)
// DefaultBuiltinActions returns the read-only seed actions.
func DefaultBuiltinActions() []Action {
now := time.Now().UTC()
return []Action{
{
ID: BuiltinActionUptimeID,
Name: "Uptime",
Description: "Show how long the target system has been running.",
Kind: ActionKindShell,
Body: "uptime",
Env: []ActionEnvVar{},
Builtin: true,
CreatedAt: now,
UpdatedAt: now,
},
{
ID: BuiltinActionDiskUsageID,
Name: "Disk usage",
Description: "Show filesystem disk space usage on the target.",
Kind: ActionKindShell,
Body: "df -h",
Env: []ActionEnvVar{},
Builtin: true,
CreatedAt: now,
UpdatedAt: now,
},
{
ID: BuiltinActionSysInfoID,
Name: "System info",
Description: "Print kernel and OS release details from the target.",
Kind: ActionKindScript,
Body: `uname -a
if [ -f /etc/os-release ]; then
. /etc/os-release
echo "OS: ${NAME:-unknown} ${VERSION:-}"
fi`,
Env: []ActionEnvVar{},
Builtin: true,
CreatedAt: now,
UpdatedAt: now,
},
}
}
// LoadActions reads and parses actions.json from dir.
func LoadActions(dir string) (ActionStore, error) {
path := filepath.Join(dir, ActionsFileName)
payload, err := os.ReadFile(path)
if err != nil {
return ActionStore{}, err
}
var store ActionStore
if err := json.Unmarshal(payload, &store); err != nil {
return ActionStore{}, fmt.Errorf("parse actions: %w", err)
}
if store.Actions == nil {
store.Actions = []Action{}
}
for index := range store.Actions {
if store.Actions[index].Env == nil {
store.Actions[index].Env = []ActionEnvVar{}
}
}
return store, nil
}
// LoadActionsOrSeed returns seeded builtins (and persists them) when actions.json is missing.
func LoadActionsOrSeed(dir string) (ActionStore, error) {
store, err := LoadActions(dir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
seeded := ActionStore{Actions: DefaultBuiltinActions()}
if saveErr := SaveActions(dir, seeded); saveErr != nil {
return ActionStore{}, saveErr
}
return seeded, nil
}
return ActionStore{}, err
}
return store, nil
}
// SaveActions writes actions.json to dir with mode 0640.
func SaveActions(dir string, store ActionStore) error {
if err := ensureConfigDir(dir); err != nil {
return err
}
if store.Actions == nil {
store.Actions = []Action{}
}
for index := range store.Actions {
if store.Actions[index].Env == nil {
store.Actions[index].Env = []ActionEnvVar{}
}
}
payload, err := json.MarshalIndent(store, "", " ")
if err != nil {
return fmt.Errorf("encode actions: %w", err)
}
payload = append(payload, '\n')
path := filepath.Join(dir, ActionsFileName)
if err := os.WriteFile(path, payload, 0o640); err != nil {
return fmt.Errorf("write actions: %w", err)
}
return nil
}
+68
View File
@@ -0,0 +1,68 @@
package settings
import (
"os"
"path/filepath"
"testing"
)
func TestLoadActionsOrSeedCreatesBuiltins(t *testing.T) {
dir := t.TempDir()
store, err := LoadActionsOrSeed(dir)
if err != nil {
t.Fatalf("LoadActionsOrSeed: %v", err)
}
if len(store.Actions) != 3 {
t.Fatalf("expected 3 builtins, got %d", len(store.Actions))
}
for _, action := range store.Actions {
if !action.Builtin {
t.Fatalf("expected builtin action, got %#v", action)
}
}
path := filepath.Join(dir, ActionsFileName)
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected actions.json to exist: %v", err)
}
reloaded, err := LoadActions(dir)
if err != nil {
t.Fatalf("LoadActions: %v", err)
}
if len(reloaded.Actions) != 3 {
t.Fatalf("reloaded len = %d", len(reloaded.Actions))
}
}
func TestActionsRoundTrip(t *testing.T) {
dir := t.TempDir()
store := ActionStore{
Actions: append(DefaultBuiltinActions(), Action{
ID: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
Name: "Custom",
Description: "A custom shell action",
Kind: ActionKindShell,
Body: "echo hello",
Env: []ActionEnvVar{
{Name: "APP_HOME", Value: "/opt/app"},
},
Builtin: false,
}),
}
if err := SaveActions(dir, store); err != nil {
t.Fatalf("SaveActions: %v", err)
}
loaded, err := LoadActions(dir)
if err != nil {
t.Fatalf("LoadActions: %v", err)
}
if len(loaded.Actions) != 4 {
t.Fatalf("loaded len = %d", len(loaded.Actions))
}
custom := loaded.Actions[3]
if custom.Name != "Custom" || len(custom.Env) != 1 || custom.Env[0].Name != "APP_HOME" {
t.Fatalf("custom = %#v", custom)
}
}
+77
View File
@@ -0,0 +1,77 @@
package settings
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
)
// MaxAuthAuditEvents is the retention cap for auth-audit.json.
const MaxAuthAuditEvents = 1000
// LoadAuthAudit reads and parses auth-audit.json from dir.
func LoadAuthAudit(dir string) (AuthAuditStore, error) {
path := filepath.Join(dir, AuthAuditFileName)
payload, err := os.ReadFile(path)
if err != nil {
return AuthAuditStore{}, err
}
var store AuthAuditStore
if err := json.Unmarshal(payload, &store); err != nil {
return AuthAuditStore{}, fmt.Errorf("parse auth audit: %w", err)
}
if store.Events == nil {
store.Events = []AuthAuditEvent{}
}
return store, nil
}
// LoadAuthAuditOrEmpty returns an empty store when auth-audit.json is missing.
func LoadAuthAuditOrEmpty(dir string) (AuthAuditStore, error) {
store, err := LoadAuthAudit(dir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return AuthAuditStore{Events: []AuthAuditEvent{}}, nil
}
return AuthAuditStore{}, err
}
return store, nil
}
// SaveAuthAudit writes auth-audit.json to dir with mode 0640.
func SaveAuthAudit(dir string, store AuthAuditStore) error {
if err := ensureConfigDir(dir); err != nil {
return err
}
if store.Events == nil {
store.Events = []AuthAuditEvent{}
}
payload, err := json.MarshalIndent(store, "", " ")
if err != nil {
return fmt.Errorf("encode auth audit: %w", err)
}
payload = append(payload, '\n')
path := filepath.Join(dir, AuthAuditFileName)
if err := os.WriteFile(path, payload, 0o640); err != nil {
return fmt.Errorf("write auth audit: %w", err)
}
return nil
}
// AppendAuthAuditEvent appends event and trims to MaxAuthAuditEvents (newest kept).
func AppendAuthAuditEvent(dir string, event AuthAuditEvent) error {
store, err := LoadAuthAuditOrEmpty(dir)
if err != nil {
return err
}
store.Events = append(store.Events, event)
if len(store.Events) > MaxAuthAuditEvents {
store.Events = store.Events[len(store.Events)-MaxAuthAuditEvents:]
}
return SaveAuthAudit(dir, store)
}
@@ -0,0 +1,81 @@
package settings
import (
"testing"
"time"
)
func TestAuthAuditRoundTripAndCap(t *testing.T) {
dir := t.TempDir()
store, err := LoadAuthAuditOrEmpty(dir)
if err != nil {
t.Fatalf("LoadAuthAuditOrEmpty: %v", err)
}
if len(store.Events) != 0 {
t.Fatalf("expected empty, got %d", len(store.Events))
}
event := AuthAuditEvent{
ID: "evt-1",
At: time.Now().UTC(),
Action: "login",
Outcome: AuthAuditOutcomeSuccess,
Category: AuthAuditCategoryAuth,
Actor: "admin",
}
if err := AppendAuthAuditEvent(dir, event); err != nil {
t.Fatalf("AppendAuthAuditEvent: %v", err)
}
loaded, err := LoadAuthAudit(dir)
if err != nil {
t.Fatalf("LoadAuthAudit: %v", err)
}
if len(loaded.Events) != 1 || loaded.Events[0].Action != "login" {
t.Fatalf("loaded = %+v", loaded)
}
if loaded.Events[0].Outcome != AuthAuditOutcomeSuccess {
t.Fatalf("outcome = %q", loaded.Events[0].Outcome)
}
}
func TestAppendAuthAuditEventTrimsToCap(t *testing.T) {
dir := t.TempDir()
originalCap := MaxAuthAuditEvents
store := AuthAuditStore{Events: make([]AuthAuditEvent, 0, MaxAuthAuditEvents+5)}
for index := 0; index < MaxAuthAuditEvents+5; index++ {
store.Events = append(store.Events, AuthAuditEvent{
ID: "evt",
At: time.Now().UTC(),
Action: "login",
Outcome: AuthAuditOutcomeSuccess,
Category: AuthAuditCategoryAuth,
Actor: "admin",
Detail: string(rune('a' + (index % 26))),
})
}
if err := SaveAuthAudit(dir, store); err != nil {
t.Fatalf("SaveAuthAudit: %v", err)
}
if err := AppendAuthAuditEvent(dir, AuthAuditEvent{
ID: "newest",
At: time.Now().UTC(),
Action: "logout",
Outcome: AuthAuditOutcomeSuccess,
Category: AuthAuditCategoryAuth,
Actor: "admin",
}); err != nil {
t.Fatalf("AppendAuthAuditEvent: %v", err)
}
loaded, err := LoadAuthAudit(dir)
if err != nil {
t.Fatalf("LoadAuthAudit: %v", err)
}
if len(loaded.Events) != originalCap {
t.Fatalf("len = %d, want %d", len(loaded.Events), originalCap)
}
if loaded.Events[len(loaded.Events)-1].ID != "newest" {
t.Fatalf("last id = %q", loaded.Events[len(loaded.Events)-1].ID)
}
}
@@ -0,0 +1,227 @@
package settings
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
var diskSafeFilenamePattern = regexp.MustCompile(`[^A-Za-z0-9._-]+`)
// LoadNodeActionGroups reads and parses node-action-groups.json from dir.
func LoadNodeActionGroups(dir string) (NodeActionGroupStore, error) {
path := filepath.Join(dir, NodeActionGroupsFileName)
payload, err := os.ReadFile(path)
if err != nil {
return NodeActionGroupStore{}, err
}
var store NodeActionGroupStore
if err := json.Unmarshal(payload, &store); err != nil {
return NodeActionGroupStore{}, fmt.Errorf("parse node action groups: %w", err)
}
normalizeNodeActionGroupStore(&store)
return store, nil
}
// LoadNodeActionGroupsOrEmpty returns an empty store when the file is missing.
func LoadNodeActionGroupsOrEmpty(dir string) (NodeActionGroupStore, error) {
store, err := LoadNodeActionGroups(dir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return NodeActionGroupStore{Groups: []NodeActionGroup{}}, nil
}
return NodeActionGroupStore{}, err
}
return store, nil
}
// SaveNodeActionGroups writes node-action-groups.json to dir with mode 0640.
func SaveNodeActionGroups(dir string, store NodeActionGroupStore) error {
if err := ensureConfigDir(dir); err != nil {
return err
}
normalizeNodeActionGroupStore(&store)
payload, err := json.MarshalIndent(store, "", " ")
if err != nil {
return fmt.Errorf("encode node action groups: %w", err)
}
payload = append(payload, '\n')
path := filepath.Join(dir, NodeActionGroupsFileName)
if err := os.WriteFile(path, payload, 0o640); err != nil {
return fmt.Errorf("write node action groups: %w", err)
}
return nil
}
func normalizeNodeActionGroupStore(store *NodeActionGroupStore) {
if store.Groups == nil {
store.Groups = []NodeActionGroup{}
}
for index := range store.Groups {
if store.Groups[index].Items == nil {
store.Groups[index].Items = []NodeActionItem{}
}
if store.Groups[index].Schedule.Times == nil {
store.Groups[index].Schedule.Times = []ActionGroupTimeSpec{}
}
for itemIndex := range store.Groups[index].Items {
if store.Groups[index].Items[itemIndex].Env == nil {
store.Groups[index].Items[itemIndex].Env = []ActionEnvVar{}
}
}
}
}
// DiskSafeFilename converts a group name into a safe filename stem.
func DiskSafeFilename(name string) string {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
return "unnamed"
}
safe := diskSafeFilenamePattern.ReplaceAllString(trimmed, "_")
safe = strings.Trim(safe, "._-")
if safe == "" {
return "unnamed"
}
if len(safe) > 120 {
safe = safe[:120]
}
return safe
}
// FindNodeActionGroupIndex returns the index of a group by id, or -1.
func FindNodeActionGroupIndex(groups []NodeActionGroup, groupID string) int {
for index := range groups {
if groups[index].ID == groupID {
return index
}
}
return -1
}
// FindNodeActionItemIndex returns the index of an item by id, or -1.
func FindNodeActionItemIndex(items []NodeActionItem, itemID string) int {
for index := range items {
if items[index].ID == itemID {
return index
}
}
return -1
}
// GroupsForNode returns groups belonging to nodeID.
func GroupsForNode(store NodeActionGroupStore, nodeID string) []NodeActionGroup {
result := make([]NodeActionGroup, 0)
for _, group := range store.Groups {
if group.NodeID == nodeID {
result = append(result, group)
}
}
return result
}
// UpdateGroupLastRunAt sets LastRunAt and UpdatedAt for a group and saves.
func UpdateGroupLastRunAt(dir string, groupID string, at time.Time) error {
store, err := LoadNodeActionGroupsOrEmpty(dir)
if err != nil {
return err
}
index := FindNodeActionGroupIndex(store.Groups, groupID)
if index < 0 {
return fmt.Errorf("action group not found")
}
runAt := at.UTC()
store.Groups[index].LastRunAt = &runAt
store.Groups[index].UpdatedAt = runAt
return SaveNodeActionGroups(dir, store)
}
// IsScheduleDue reports whether the group should run at now given its schedule and LastRunAt.
func IsScheduleDue(group NodeActionGroup, now time.Time) bool {
schedule := group.Schedule
if !schedule.Enabled {
return false
}
localNow := now.In(time.Local)
switch schedule.Kind {
case ActionGroupScheduleInterval:
if schedule.EverySeconds < 1 {
return false
}
if group.LastRunAt == nil {
return true
}
elapsed := localNow.Sub(group.LastRunAt.In(time.Local))
return elapsed >= time.Duration(schedule.EverySeconds)*time.Second
case ActionGroupScheduleTimes:
if len(schedule.Times) == 0 {
return false
}
for _, spec := range schedule.Times {
if !timeSpecMatches(spec, localNow) {
continue
}
if group.LastRunAt == nil {
return true
}
lastLocal := group.LastRunAt.In(time.Local)
// Fire once per matching minute window.
sameMinute := lastLocal.Year() == localNow.Year() &&
lastLocal.YearDay() == localNow.YearDay() &&
lastLocal.Hour() == localNow.Hour() &&
lastLocal.Minute() == localNow.Minute()
if !sameMinute {
return true
}
}
return false
default:
return false
}
}
func timeSpecMatches(spec ActionGroupTimeSpec, now time.Time) bool {
hour, minute, ok := parseHHMM(spec.Time)
if !ok {
return false
}
if now.Hour() != hour || now.Minute() != minute {
return false
}
if len(spec.DaysOfWeek) == 0 {
return true
}
weekday := int(now.Weekday())
for _, day := range spec.DaysOfWeek {
if day == weekday {
return true
}
}
return false
}
func parseHHMM(value string) (hour int, minute int, ok bool) {
parts := strings.Split(strings.TrimSpace(value), ":")
if len(parts) != 2 {
return 0, 0, false
}
if _, err := fmt.Sscanf(parts[0], "%d", &hour); err != nil {
return 0, 0, false
}
if _, err := fmt.Sscanf(parts[1], "%d", &minute); err != nil {
return 0, 0, false
}
if hour < 0 || hour > 23 || minute < 0 || minute > 59 {
return 0, 0, false
}
return hour, minute, true
}
@@ -0,0 +1,160 @@
package settings
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestDiskSafeFilename(t *testing.T) {
cases := map[string]string{
"Daily Backup": "Daily_Backup",
" hello!! ": "hello",
"../etc/passwd": "etc_passwd",
"": "unnamed",
"a/b\\c:d*e?f": "a_b_c_d_e_f",
}
for input, want := range cases {
got := DiskSafeFilename(input)
if got != want {
t.Fatalf("DiskSafeFilename(%q) = %q, want %q", input, got, want)
}
}
}
func TestNodeActionGroupsRoundTrip(t *testing.T) {
dir := t.TempDir()
now := time.Now().UTC()
store := NodeActionGroupStore{
Groups: []NodeActionGroup{
{
ID: "g1",
NodeID: "n1",
Name: "Nightly",
Schedule: ActionGroupSchedule{
Enabled: true,
Kind: ActionGroupScheduleInterval,
EverySeconds: 60,
Times: []ActionGroupTimeSpec{},
},
Items: []NodeActionItem{
{
ID: "i1",
Source: ActionItemSourceLibrary,
LibraryActionID: BuiltinActionUptimeID,
Env: []ActionEnvVar{},
},
},
CreatedAt: now,
UpdatedAt: now,
},
},
}
if err := SaveNodeActionGroups(dir, store); err != nil {
t.Fatalf("SaveNodeActionGroups: %v", err)
}
loaded, err := LoadNodeActionGroups(dir)
if err != nil {
t.Fatalf("LoadNodeActionGroups: %v", err)
}
if len(loaded.Groups) != 1 || loaded.Groups[0].Name != "Nightly" {
t.Fatalf("loaded = %#v", loaded)
}
if len(loaded.Groups[0].Items) != 1 {
t.Fatalf("items = %#v", loaded.Groups[0].Items)
}
}
func TestIsScheduleDueInterval(t *testing.T) {
now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.Local)
group := NodeActionGroup{
Schedule: ActionGroupSchedule{
Enabled: true,
Kind: ActionGroupScheduleInterval,
EverySeconds: 300,
},
}
if !IsScheduleDue(group, now) {
t.Fatal("expected due when never run")
}
last := now.Add(-301 * time.Second)
group.LastRunAt = &last
if !IsScheduleDue(group, now) {
t.Fatal("expected due after interval elapsed")
}
recent := now.Add(-10 * time.Second)
group.LastRunAt = &recent
if IsScheduleDue(group, now) {
t.Fatal("expected not due within interval")
}
}
func TestIsScheduleDueTimes(t *testing.T) {
now := time.Date(2026, 7, 19, 14, 30, 10, 0, time.Local) // Sunday
group := NodeActionGroup{
Schedule: ActionGroupSchedule{
Enabled: true,
Kind: ActionGroupScheduleTimes,
Times: []ActionGroupTimeSpec{
{Time: "14:30", DaysOfWeek: []int{0}},
},
},
}
if !IsScheduleDue(group, now) {
t.Fatal("expected due at matching time")
}
sameMinute := now
group.LastRunAt = &sameMinute
if IsScheduleDue(group, now) {
t.Fatal("expected not due again in same minute")
}
group.LastRunAt = nil
group.Schedule.Times[0].DaysOfWeek = []int{1} // Monday only
if IsScheduleDue(group, now) {
t.Fatal("expected not due on wrong weekday")
}
}
func TestActionLogAppendAndList(t *testing.T) {
dir := t.TempDir()
record := ActionGroupRunRecord{
ID: "run-1",
NodeID: "node-abc",
GroupID: "group-1",
GroupName: "Nightly Jobs",
Trigger: ActionRunTriggerManual,
StartedAt: time.Now().UTC(),
FinishedAt: time.Now().UTC(),
Actions: []ActionItemRunResult{},
}
if err := AppendActionLogRun(dir, record); err != nil {
t.Fatalf("AppendActionLogRun: %v", err)
}
path := ActionLogFilePath(dir, "node-abc", "Nightly Jobs")
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected log file at %s: %v", path, err)
}
files, err := ListActionLogFiles(dir, "node-abc")
if err != nil {
t.Fatalf("ListActionLogFiles: %v", err)
}
if len(files) != 1 {
t.Fatalf("files = %#v", files)
}
wantName := DiskSafeFilename("Nightly Jobs") + ".json"
if files[0].Filename != wantName {
t.Fatalf("filename = %q want %q", files[0].Filename, wantName)
}
loaded, err := ReadActionLogFileByName(dir, "node-abc", wantName)
if err != nil {
t.Fatalf("ReadActionLogFileByName: %v", err)
}
if len(loaded.Runs) != 1 || loaded.Runs[0].ID != "run-1" {
t.Fatalf("loaded = %#v", loaded)
}
if _, err := ReadActionLogFileByName(dir, "node-abc", "../etc/passwd"); err == nil {
t.Fatal("expected path traversal rejected")
}
_ = filepath.Base(wantName)
}
+4
View File
@@ -19,6 +19,10 @@ const (
NodesFileName = "nodes.json"
NodeKeysFileName = "node-keys.enc"
NodeAuditFileName = "node-audit.json"
AuthAuditFileName = "auth-audit.json"
ActionsFileName = "actions.json"
NodeActionGroupsFileName = "node-action-groups.json"
ActionLogsDirName = "action-logs"
)
// ResolveDir returns the config directory using precedence:
+197
View File
@@ -105,6 +105,14 @@ type Node struct {
PublicKey string `json:"public_key"`
KeyAlgo string `json:"key_algo"`
CreatedAt time.Time `json:"created_at"`
// Health check configuration and last result (persisted).
HealthCheckIntervalSeconds int `json:"health_check_interval_seconds"`
HealthLastCheckedAt *time.Time `json:"health_last_checked_at,omitempty"`
HealthPingOK *bool `json:"health_ping_ok,omitempty"`
HealthSSHOK *bool `json:"health_ssh_ok,omitempty"`
HealthOK *bool `json:"health_ok,omitempty"`
HealthMessage string `json:"health_message,omitempty"`
}
// NodeStore is the plain JSON payload in nodes.json.
@@ -112,6 +120,159 @@ type NodeStore struct {
Nodes []Node `json:"nodes"`
}
// ActionKind identifies how an action body is interpreted when run on a node.
type ActionKind string
const (
ActionKindShell ActionKind = "shell"
ActionKindScript ActionKind = "script"
)
// ActionEnvVar is one environment variable applied before an action runs.
type ActionEnvVar struct {
Name string `json:"name"`
Value string `json:"value"`
}
// Action is a named shell command or script that can run on managed nodes.
type Action struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Kind ActionKind `json:"kind"`
Body string `json:"body"`
Env []ActionEnvVar `json:"env"`
RequiresSudo bool `json:"requires_sudo"`
Builtin bool `json:"builtin"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ActionStore is the plain JSON payload in actions.json.
type ActionStore struct {
Actions []Action `json:"actions"`
}
// ActionItemSource identifies whether a node action item is a library ref or local copy.
type ActionItemSource string
const (
ActionItemSourceLibrary ActionItemSource = "library"
ActionItemSourceLocal ActionItemSource = "local"
)
// ActionGroupScheduleKind identifies how an action group is scheduled.
type ActionGroupScheduleKind string
const (
ActionGroupScheduleInterval ActionGroupScheduleKind = "interval"
ActionGroupScheduleTimes ActionGroupScheduleKind = "times"
)
// ActionGroupTimeSpec is one clock time (and optional weekdays) for a times schedule.
type ActionGroupTimeSpec struct {
Time string `json:"time"` // "HH:MM" in server local time
DaysOfWeek []int `json:"days_of_week,omitempty"` // 0=Sun..6=Sat; empty = every day
}
// ActionGroupSchedule configures when an action group runs automatically.
type ActionGroupSchedule struct {
Enabled bool `json:"enabled"`
Kind ActionGroupScheduleKind `json:"kind"`
EverySeconds int `json:"every_seconds,omitempty"`
Times []ActionGroupTimeSpec `json:"times,omitempty"`
}
// NodeActionItem is one ordered action inside a node action group.
type NodeActionItem struct {
ID string `json:"id"`
Source ActionItemSource `json:"source"`
LibraryActionID string `json:"library_action_id,omitempty"`
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
Kind ActionKind `json:"kind,omitempty"`
Body string `json:"body,omitempty"`
Env []ActionEnvVar `json:"env,omitempty"`
RequiresSudo bool `json:"requires_sudo,omitempty"` // local items; library refs inherit from library
SetVariable string `json:"set_variable,omitempty"` // capture trimmed stdout into this run-scoped name
RunIf string `json:"run_if,omitempty"` // e.g. "if $UpdateCount >= 1"
}
// NodeActionGroup is a named ordered set of actions with one schedule on a node.
type NodeActionGroup struct {
ID string `json:"id"`
NodeID string `json:"node_id"`
Name string `json:"name"`
Schedule ActionGroupSchedule `json:"schedule"`
Items []NodeActionItem `json:"items"`
LastRunAt *time.Time `json:"last_run_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// NodeActionGroupStore is the plain JSON payload in node-action-groups.json.
type NodeActionGroupStore struct {
Groups []NodeActionGroup `json:"groups"`
}
// ActionRunTrigger identifies what started an action run.
type ActionRunTrigger string
const (
ActionRunTriggerManual ActionRunTrigger = "manual"
ActionRunTriggerSchedule ActionRunTrigger = "schedule"
)
// ActionItemRunResult is the captured output of one action within a run.
type ActionItemRunResult struct {
ItemID string `json:"item_id"`
ActionName string `json:"action_name"`
Source string `json:"source"`
Kind ActionKind `json:"kind,omitempty"`
RemoteCommand string `json:"remote_command,omitempty"`
SSHUsername string `json:"ssh_username,omitempty"`
ExitCode int `json:"exit_code"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
Error string `json:"error,omitempty"`
Skipped bool `json:"skipped,omitempty"`
SkipReason string `json:"skip_reason,omitempty"`
SetVariable string `json:"set_variable,omitempty"`
VariableValue string `json:"variable_value,omitempty"`
StartedAt time.Time `json:"started_at"`
FinishedAt time.Time `json:"finished_at"`
}
// ActionGroupRunRecord is one execution of a group (or a single-item manual run).
type ActionGroupRunRecord struct {
ID string `json:"id"`
NodeID string `json:"node_id"`
GroupID string `json:"group_id"`
GroupName string `json:"group_name"`
Trigger ActionRunTrigger `json:"trigger"`
Actor string `json:"actor,omitempty"`
StartedAt time.Time `json:"started_at"`
FinishedAt time.Time `json:"finished_at"`
Actions []ActionItemRunResult `json:"actions"`
}
// ActionLogFile is the JSON payload for one node/group log file on disk.
type ActionLogFile struct {
NodeID string `json:"node_id"`
GroupID string `json:"group_id"`
GroupName string `json:"group_name"`
Runs []ActionGroupRunRecord `json:"runs"`
}
// ActionLogFileInfo is metadata for listing log files without reading full content.
type ActionLogFileInfo struct {
Filename string `json:"filename"`
GroupName string `json:"group_name"`
GroupID string `json:"group_id,omitempty"`
ModTime time.Time `json:"mod_time"`
SizeBytes int64 `json:"size_bytes"`
}
// NodeKeyEntry holds private key material for one node, keyed by node UUID.
type NodeKeyEntry struct {
NodeID string `json:"node_id"`
@@ -134,6 +295,7 @@ const (
NodeAuditActionCreate NodeAuditAction = "create"
NodeAuditActionDelete NodeAuditAction = "delete"
NodeAuditActionTestSSH NodeAuditAction = "test_ssh"
NodeAuditActionHealthCheck NodeAuditAction = "health_check"
NodeAuditActionUpdate NodeAuditAction = "update"
)
@@ -154,6 +316,41 @@ type NodeAuditStore struct {
Events []NodeAuditEvent `json:"events"`
}
// AuthAuditOutcome is success or failure for an auth/user/group audit event.
type AuthAuditOutcome string
const (
AuthAuditOutcomeSuccess AuthAuditOutcome = "success"
AuthAuditOutcomeFailure AuthAuditOutcome = "failure"
)
// AuthAuditCategory groups auth audit events for UI styling and filtering.
type AuthAuditCategory string
const (
AuthAuditCategoryAuth AuthAuditCategory = "auth"
AuthAuditCategoryUser AuthAuditCategory = "user"
AuthAuditCategoryGroup AuthAuditCategory = "group"
AuthAuditCategorySelf AuthAuditCategory = "self"
)
// AuthAuditEvent is one append-only record of a login or user/group change.
type AuthAuditEvent struct {
ID string `json:"id"`
At time.Time `json:"at"`
Action string `json:"action"`
Outcome AuthAuditOutcome `json:"outcome"`
Category AuthAuditCategory `json:"category"`
Actor string `json:"actor"`
Target string `json:"target,omitempty"`
Detail string `json:"detail,omitempty"`
}
// AuthAuditStore is the plain JSON payload in auth-audit.json.
type AuthAuditStore struct {
Events []AuthAuditEvent `json:"events"`
}
// UserCredential is one account stored in passwords.enc.
type UserCredential struct {
ID string `json:"id"`
+52 -25
View File
@@ -1,32 +1,59 @@
# React + TypeScript + Vite
# ClusterCanvas web UI
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
React 19 + TypeScript + Vite SPA for ClusterCanvas. It talks to the Go API over `/api` and `/health`.
Currently, two official plugins are available:
For service config, remotedev, and repo-wide build/test targets, see the [root README](../README.md).
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## Develop
## React Compiler
From this directory:
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```bash
npm install
npm run dev
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
Or from the repo root: `make webui` (UI only) or `make dev` (API + UI).
The Vite dev server listens on `:5173` and proxies `/api` and `/health` to `http://localhost:8080` (see `vite.config.ts`).
Optional: set `VITE_API_BASE_URL` if you call the API without the Vite proxy (defaults to `http://localhost:8080`). An empty value means same-origin requests (used by `make remotedev` builds).
## Scripts
| Script | Purpose |
|--------|---------|
| `npm run dev` | Vite dev server with HMR |
| `npm run build` | Typecheck + production build to `dist/` |
| `npm run preview` | Serve the production build locally |
| `npm test` | Vitest (jsdom) once |
| `npm run test:watch` | Vitest in watch mode |
| `npm run lint` | Oxlint (see `.oxlintrc.json`) |
## App map
Flow: **setup wizard****login** → main shell.
| Area | Notes |
|------|-------|
| Overview | Placeholder for a future dashboard |
| Containers / Docker / VMs | UI categories for manually registered SSH hosts |
| Activity | Node and auth audit logs (when permitted) |
| Profile | Password change and TOTP enroll/disable |
| Configuration | Users, groups, roles catalog, actions, security, network; Integrations / Advanced are placeholders |
Node detail tabs: **Overview** (metadata), **Actions** (groups, schedules, run), **Logs** (action run history).
### Key source files
| Path | Role |
|------|------|
| `src/App.tsx` | Shell, routing state, main screens |
| `src/api/client.ts` | HTTP client and API types |
| `src/navigation.ts` | Section / tab / URL helpers |
| `src/NodeDetailPanel.tsx` | Per-node overview, actions, logs |
| `src/SetupWizard.tsx` | First-run setup |
## Stack
React 19, Vite 8, Vitest + Testing Library, Oxlint. React Compiler is not enabled.
+974 -17
View File
File diff suppressed because it is too large Load Diff
+487 -6
View File
@@ -1,13 +1,11 @@
import { render, screen } from '@testing-library/react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import App from './App'
import { THEME_STORAGE_KEY } from './theme'
function stubFetchOk() {
vi.stubGlobal(
'fetch',
vi.fn().mockImplementation((input: RequestInfo | URL) => {
const fetchMock = vi.fn().mockImplementation((input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString()
if (url.includes('/api/v1/setup/status')) {
@@ -17,6 +15,13 @@ function stubFetchOk() {
})
}
if (url.includes('/api/v1/auth/logout')) {
return Promise.resolve({
ok: true,
json: async () => ({ ok: true }),
})
}
if (url.includes('/api/v1/auth/me')) {
return Promise.resolve({
ok: true,
@@ -33,9 +38,14 @@ function stubFetchOk() {
'nodes.delete',
'jobs.read',
'jobs.run',
'actions.create',
'actions.update',
'actions.delete',
'actions.execute',
'users.manage',
'secrets.manage',
'roles.manage',
'logs.read',
],
}),
})
@@ -58,6 +68,13 @@ function stubFetchOk() {
})
}
if (url.includes('/api/v1/node-logs') || url.includes('/api/v1/auth-logs')) {
return Promise.resolve({
ok: true,
json: async () => ({ events: [] }),
})
}
if (url.includes('/api/v1/groups')) {
return Promise.resolve({
ok: true,
@@ -74,9 +91,14 @@ function stubFetchOk() {
'nodes.delete',
'jobs.read',
'jobs.run',
'actions.create',
'actions.update',
'actions.delete',
'actions.execute',
'users.manage',
'secrets.manage',
'roles.manage',
'logs.read',
],
},
],
@@ -145,6 +167,38 @@ function stubFetchOk() {
})
}
if (url.includes('/api/v1/actions')) {
return Promise.resolve({
ok: true,
json: async () => ({
actions: [
{
id: '00000000-0000-4000-8000-000000000001',
name: 'Uptime',
description: 'Show how long the target system has been running.',
kind: 'shell',
body: 'uptime',
env: [],
builtin: true,
created_at: '2026-01-01T00:00:00.000Z',
updated_at: '2026-01-01T00:00:00.000Z',
},
{
id: 'custom-1',
name: 'Echo host',
description: 'Print the target IP',
kind: 'shell',
body: 'echo {{node.ip}}',
env: [{ name: 'APP_HOME', value: '/opt/app' }],
builtin: false,
created_at: '2026-01-02T00:00:00.000Z',
updated_at: '2026-01-02T00:00:00.000Z',
},
],
}),
})
}
if (url.includes('/api/v1/nodes')) {
return Promise.resolve({
ok: true,
@@ -156,8 +210,10 @@ function stubFetchOk() {
ok: true,
json: async () => ({}),
})
}),
)
})
vi.stubGlobal('fetch', fetchMock)
return fetchMock
}
function stubMatchMedia(prefersDark: boolean) {
@@ -188,6 +244,7 @@ describe('App', () => {
localStorage.clear()
delete document.documentElement.dataset.theme
window.history.replaceState(null, '', '/')
vi.useRealTimers()
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
@@ -210,6 +267,7 @@ describe('App', () => {
expect(
screen.getByRole('button', { name: 'Containers' }),
).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Activity' })).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Configuration' }),
).toBeInTheDocument()
@@ -221,6 +279,370 @@ describe('App', () => {
expect(screen.getByText('Coming soon')).toBeInTheDocument()
})
it('shows node count badges on resource nav items', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockImplementation((input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString()
if (url.includes('/api/v1/setup/status')) {
return Promise.resolve({
ok: true,
json: async () => ({ completed: true, client_ip: '127.0.0.1' }),
})
}
if (url.includes('/api/v1/auth/me')) {
return Promise.resolve({
ok: true,
json: async () => ({
user_id: 'u1',
username: 'Admin',
groups: ['Administrators'],
totp_confirmed: false,
totp_enabled: false,
permissions: [
'nodes.read',
'logs.read',
],
}),
})
}
if (url.includes('/api/v1/status')) {
return Promise.resolve({
ok: true,
json: async () => ({
service: 'clustercanvas',
version: '0.1.0',
}),
})
}
if (url.includes('/api/v1/auth-logs')) {
return Promise.resolve({
ok: true,
json: async () => ({ events: [] }),
})
}
if (url.includes('/api/v1/nodes')) {
return Promise.resolve({
ok: true,
json: async () => ({
nodes: [
{ id: 'c1', kind: 'container', name: 'alpha' },
{ id: 'c2', kind: 'container', name: 'beta' },
{ id: 'd1', kind: 'docker', name: 'dock' },
],
}),
})
}
return Promise.resolve({
ok: true,
json: async () => ({}),
})
}),
)
render(<App />)
const containersButton = await screen.findByRole('button', {
name: 'Containers',
})
await waitFor(() => {
expect(
containersButton.querySelector('.nav-count-badge'),
).toHaveTextContent('2')
})
expect(
screen
.getByRole('button', { name: 'Docker' })
.querySelector('.nav-count-badge'),
).toHaveTextContent('1')
expect(
screen
.getByRole('button', { name: 'Virtual Machines' })
.querySelector('.nav-count-badge'),
).toHaveTextContent('0')
expect(
screen
.getByRole('button', { name: 'Activity' })
.querySelector('.nav-count-badge-alert'),
).toBeNull()
})
it('shows a red Activity badge for auth failures today', async () => {
const todayIso = new Date(
new Date().getFullYear(),
new Date().getMonth(),
new Date().getDate(),
12,
0,
0,
).toISOString()
const yesterdayIso = new Date(
new Date().getFullYear(),
new Date().getMonth(),
new Date().getDate() - 1,
12,
0,
0,
).toISOString()
vi.stubGlobal(
'fetch',
vi.fn().mockImplementation((input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString()
if (url.includes('/api/v1/setup/status')) {
return Promise.resolve({
ok: true,
json: async () => ({ completed: true, client_ip: '127.0.0.1' }),
})
}
if (url.includes('/api/v1/auth/me')) {
return Promise.resolve({
ok: true,
json: async () => ({
user_id: 'u1',
username: 'Admin',
groups: ['Administrators'],
totp_confirmed: false,
totp_enabled: false,
permissions: ['nodes.read', 'logs.read'],
}),
})
}
if (url.includes('/api/v1/status')) {
return Promise.resolve({
ok: true,
json: async () => ({
service: 'clustercanvas',
version: '0.1.0',
}),
})
}
if (url.includes('/api/v1/auth-logs')) {
return Promise.resolve({
ok: true,
json: async () => ({
events: [
{
id: 'a1',
at: todayIso,
action: 'login',
outcome: 'failure',
category: 'auth',
actor: 'eve',
},
{
id: 'a2',
at: todayIso,
action: 'login',
outcome: 'failure',
category: 'auth',
actor: 'mallory',
},
{
id: 'a3',
at: yesterdayIso,
action: 'login',
outcome: 'failure',
category: 'auth',
actor: 'old',
},
{
id: 'a4',
at: todayIso,
action: 'login',
outcome: 'success',
category: 'auth',
actor: 'Admin',
},
],
}),
})
}
if (url.includes('/api/v1/nodes')) {
return Promise.resolve({
ok: true,
json: async () => ({ nodes: [] }),
})
}
return Promise.resolve({
ok: true,
json: async () => ({}),
})
}),
)
render(<App />)
const activityButton = await screen.findByRole('button', {
name: 'Activity',
})
const alertBadge = await waitFor(() => {
const badge = activityButton.querySelector('.nav-count-badge-alert')
expect(badge).not.toBeNull()
return badge as Element
})
expect(alertBadge).toHaveTextContent('2')
})
it('polls sidebar counts every 30s with idle-exempt header', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
const fetchMock = vi.fn().mockImplementation((input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString()
if (url.includes('/api/v1/setup/status')) {
return Promise.resolve({
ok: true,
json: async () => ({ completed: true, client_ip: '127.0.0.1' }),
})
}
if (url.includes('/api/v1/auth/me')) {
return Promise.resolve({
ok: true,
json: async () => ({
user_id: 'u1',
username: 'Admin',
groups: ['Administrators'],
totp_confirmed: false,
totp_enabled: false,
permissions: ['nodes.read', 'logs.read'],
}),
})
}
if (url.includes('/api/v1/status')) {
return Promise.resolve({
ok: true,
json: async () => ({
service: 'clustercanvas',
version: '0.1.0',
}),
})
}
if (url.includes('/api/v1/auth-logs')) {
return Promise.resolve({
ok: true,
json: async () => ({ events: [] }),
})
}
if (url.includes('/api/v1/nodes')) {
return Promise.resolve({
ok: true,
json: async () => ({ nodes: [] }),
})
}
return Promise.resolve({
ok: true,
json: async () => ({}),
})
})
vi.stubGlobal('fetch', fetchMock)
render(<App />)
await screen.findByRole('heading', { name: 'Cluster Canvas' })
await waitFor(() => {
expect(
fetchMock.mock.calls.some(([input]) =>
String(input).includes('/api/v1/nodes'),
),
).toBe(true)
})
const nodesCallsBeforePoll = fetchMock.mock.calls.filter(([input]) =>
String(input).includes('/api/v1/nodes'),
).length
const authCallsBeforePoll = fetchMock.mock.calls.filter(([input]) =>
String(input).includes('/api/v1/auth-logs'),
).length
await vi.advanceTimersByTimeAsync(30_000)
await waitFor(() => {
const nodesCalls = fetchMock.mock.calls.filter(([input]) =>
String(input).includes('/api/v1/nodes'),
)
expect(nodesCalls.length).toBeGreaterThan(nodesCallsBeforePoll)
})
const pollNodesCall = [...fetchMock.mock.calls]
.reverse()
.find(([input]) => String(input).includes('/api/v1/nodes'))
expect(pollNodesCall).toBeDefined()
const pollNodesHeaders = (pollNodesCall?.[1] as { headers: Headers }).headers
expect(pollNodesHeaders.get('X-Session-Idle-Exempt')).toBe('1')
const pollAuthCall = [...fetchMock.mock.calls]
.reverse()
.find(([input]) => String(input).includes('/api/v1/auth-logs'))
expect(pollAuthCall).toBeDefined()
const pollAuthHeaders = (pollAuthCall?.[1] as { headers: Headers }).headers
expect(pollAuthHeaders.get('X-Session-Idle-Exempt')).toBe('1')
expect(authCallsBeforePoll).toBeGreaterThan(0)
vi.useRealTimers()
})
it('hides Activity logs when logs.read is missing', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockImplementation((input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString()
if (url.includes('/api/v1/setup/status')) {
return Promise.resolve({
ok: true,
json: async () => ({ completed: true, client_ip: '127.0.0.1' }),
})
}
if (url.includes('/api/v1/auth/me')) {
return Promise.resolve({
ok: true,
json: async () => ({
user_id: 'u1',
username: 'Admin',
groups: ['Administrators'],
totp_confirmed: false,
totp_enabled: false,
permissions: ['nodes.read', 'users.manage'],
}),
})
}
if (url.includes('/health') || url.includes('/api/v1/status')) {
return Promise.resolve({
ok: true,
json: async () => ({ status: 'ok', service: 'clustercanvas', version: '0.1.0' }),
})
}
return Promise.resolve({ ok: true, json: async () => ({}) })
}),
)
render(<App />)
expect(
await screen.findByRole('heading', { name: 'Cluster Canvas' }),
).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Activity' }),
).not.toBeInTheDocument()
})
it('opens Profile from the sidebar and updates the URL', async () => {
const user = userEvent.setup()
stubFetchOk()
@@ -243,6 +665,32 @@ describe('App', () => {
).toBeInTheDocument()
})
it('logs out from the sidebar and shows a signed-out notice', async () => {
const user = userEvent.setup()
const fetchMock = stubFetchOk()
render(<App />)
expect(await screen.findByRole('heading', { name: 'Cluster Canvas' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Admin' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Log out' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Log out' }))
expect(
await screen.findByRole('heading', { name: 'Sign in' }),
).toBeInTheDocument()
expect(screen.getByText(/You have been logged out/i)).toBeInTheDocument()
expect(window.location.pathname).toBe('/login')
expect(
fetchMock.mock.calls.some(
([input, init]) =>
String(input).includes('/api/v1/auth/logout') &&
(init as RequestInit | undefined)?.method === 'POST',
),
).toBe(true)
})
it('restores Profile from the URL on load', async () => {
stubFetchOk()
window.history.replaceState(null, '', '/profile')
@@ -355,6 +803,7 @@ describe('App', () => {
'Users',
'Roles',
'Groups',
'Actions',
'Security',
'Integrations',
'Network',
@@ -378,9 +827,14 @@ describe('App', () => {
'nodes.delete',
'jobs.read',
'jobs.run',
'actions.create',
'actions.update',
'actions.delete',
'actions.execute',
'users.manage',
'secrets.manage',
'roles.manage',
'logs.read',
]) {
expect(screen.getByText(permission)).toBeInTheDocument()
}
@@ -395,6 +849,33 @@ describe('App', () => {
expect(screen.getByLabelText('roles.manage')).toBeInTheDocument()
expect(screen.queryByLabelText('Theme')).not.toBeInTheDocument()
await user.click(screen.getByRole('tab', { name: 'Actions' }))
expect(screen.getByRole('tab', { name: 'Actions' })).toHaveAttribute(
'aria-selected',
'true',
)
expect(await screen.findByText('Uptime')).toBeInTheDocument()
expect(screen.getByText('Echo host')).toBeInTheDocument()
expect(screen.getByText('{{cc.host}}')).toBeInTheDocument()
expect(screen.getByText('{{secret.NAME}}')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Add Action…' }),
).toBeInTheDocument()
const actionDeleteButtons = screen.getAllByRole('button', { name: 'Delete' })
expect(actionDeleteButtons[0]).toBeDisabled()
expect(actionDeleteButtons[1]).toBeEnabled()
await user.click(screen.getByRole('button', { name: 'Add Action…' }))
expect(
screen.getByRole('heading', { name: 'Add Action' }),
).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /Shell Command/i }))
expect(
screen.getByRole('heading', { name: 'New shell command' }),
).toBeInTheDocument()
expect(screen.getByLabelText('Command')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Cancel' }))
await user.click(screen.getByRole('tab', { name: 'Security' }))
expect(screen.getByRole('tab', { name: 'Security' })).toHaveAttribute(
'aria-selected',
+1464 -105
View File
File diff suppressed because it is too large Load Diff
+61
View File
@@ -0,0 +1,61 @@
import { render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { LoginPage } from './LoginPage'
describe('LoginPage', () => {
afterEach(() => {
window.history.replaceState(null, '', '/login')
vi.restoreAllMocks()
})
it('shows an idle signed-out notice from the query string', () => {
window.history.replaceState(null, '', '/login?reason=idle')
render(<LoginPage onLoggedIn={vi.fn()} />)
expect(
screen.getByText(
/You have been signed out because your idle time limit was reached/i,
),
).toBeInTheDocument()
expect(window.location.search).toBe('')
})
it('shows a logged-out notice from the query string', () => {
window.history.replaceState(null, '', '/login?reason=logout')
render(<LoginPage onLoggedIn={vi.fn()} />)
expect(screen.getByText(/You have been logged out/i)).toBeInTheDocument()
expect(window.location.search).toBe('')
})
it('does not show an idle notice without the query reason', () => {
window.history.replaceState(null, '', '/login')
render(<LoginPage onLoggedIn={vi.fn()} />)
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('shows the setup hint only when arriving from setup', () => {
window.history.replaceState(null, '', '/login?from=setup')
render(<LoginPage onLoggedIn={vi.fn()} />)
expect(
screen.getByText(/Use the administrator account created during setup/i),
).toBeInTheDocument()
expect(window.location.search).toBe('')
})
it('hides the setup hint on a normal login visit', () => {
window.history.replaceState(null, '', '/login')
render(<LoginPage onLoggedIn={vi.fn()} />)
expect(
screen.queryByText(/Use the administrator account created during setup/i),
).not.toBeInTheDocument()
})
})
+57 -2
View File
@@ -1,15 +1,64 @@
import { useState, type FormEvent } from 'react'
import { login } from './api/client'
const IDLE_SIGNED_OUT_MESSAGE =
'You have been signed out because your idle time limit was reached.'
const LOGOUT_SIGNED_OUT_MESSAGE = 'You have been logged out.'
const SETUP_HINT_MESSAGE =
'Use the administrator account created during setup.'
type LoginPageProps = {
onLoggedIn: (username: string) => void
}
function consumeLoginQueryFlag(flagName: string, expectedValue: string): boolean {
if (typeof window === 'undefined') {
return false
}
const params = new URLSearchParams(window.location.search)
if (params.get(flagName) !== expectedValue) {
return false
}
params.delete(flagName)
const nextSearch = params.toString()
const nextUrl = `${window.location.pathname}${nextSearch ? `?${nextSearch}` : ''}${window.location.hash}`
window.history.replaceState(null, '', nextUrl)
return true
}
function readSignedOutMessage(): string | null {
if (typeof window === 'undefined') {
return null
}
const params = new URLSearchParams(window.location.search)
const reason = params.get('reason')
let message: string | null = null
if (reason === 'idle') {
message = IDLE_SIGNED_OUT_MESSAGE
} else if (reason === 'logout') {
message = LOGOUT_SIGNED_OUT_MESSAGE
}
if (message === null) {
return null
}
params.delete('reason')
const nextSearch = params.toString()
const nextUrl = `${window.location.pathname}${nextSearch ? `?${nextSearch}` : ''}${window.location.hash}`
window.history.replaceState(null, '', nextUrl)
return message
}
export function LoginPage({ onLoggedIn }: LoginPageProps) {
const [username, setUsername] = useState('Admin')
const [password, setPassword] = useState('')
const [totpCode, setTotpCode] = useState('')
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [signedOutMessage] = useState<string | null>(() =>
readSignedOutMessage(),
)
const [showSetupHint] = useState(() =>
consumeLoginQueryFlag('from', 'setup'),
)
const [isSubmitting, setIsSubmitting] = useState(false)
async function handleSubmit(event: FormEvent) {
@@ -31,9 +80,15 @@ export function LoginPage({ onLoggedIn }: LoginPageProps) {
<form className="wizard-card" onSubmit={(event) => void handleSubmit(event)}>
<p className="wizard-brand">Cluster Canvas</p>
<h1 className="wizard-title">Sign in</h1>
<p className="config-hint">
Use the administrator account created during setup.
{showSetupHint ? (
<p className="config-hint">{SETUP_HINT_MESSAGE}</p>
) : null}
{signedOutMessage ? (
<p className="wizard-notice" role="status">
{signedOutMessage}
</p>
) : null}
{errorMessage ? (
<p className="wizard-error" role="alert">
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
}
+299
View File
@@ -1,16 +1,22 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
createAction,
deleteAction,
deleteGroup,
deleteNode,
deleteUser,
fetchActions,
fetchGroups,
fetchHealth,
fetchNetwork,
fetchAuthLogs,
fetchNodeLogs,
fetchSecurity,
fetchStatus,
fetchUsers,
getApiBaseUrl,
login,
patchAction,
saveNetwork,
saveSecurity,
upsertGroup,
@@ -391,6 +397,51 @@ describe('fetchNodeLogs', () => {
})
})
describe('fetchAuthLogs', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('returns parsed auth logs payload', async () => {
const events = [
{
id: 'a1',
at: '2026-01-01T00:00:00Z',
action: 'login',
outcome: 'success',
category: 'auth',
actor: 'Admin',
target: 'Admin',
},
]
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ events }),
})
const payload = await fetchAuthLogs(fetchMock)
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:8080/api/v1/auth-logs',
expect.objectContaining({ credentials: 'include' }),
)
expect(payload.events).toEqual(events)
})
it('sends idle-exempt header when requested', async () => {
const { SESSION_IDLE_EXEMPT_HEADER } = await import('./client')
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ events: [] }),
})
await fetchAuthLogs(fetchMock, { idleExempt: true })
const init = fetchMock.mock.calls[0][1] as { headers: Headers }
expect(init.headers.get(SESSION_IDLE_EXEMPT_HEADER)).toBe('1')
})
})
describe('fetchSecurity', () => {
afterEach(() => {
vi.restoreAllMocks()
@@ -560,6 +611,39 @@ describe('nodes API', () => {
)
})
it('sends idle-exempt header when requested', async () => {
const { fetchNodes, SESSION_IDLE_EXEMPT_HEADER } = await import('./client')
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ nodes: [] }),
})
await fetchNodes(undefined, fetchMock, { idleExempt: true })
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:8080/api/v1/nodes',
expect.objectContaining({
credentials: 'include',
headers: expect.any(Headers),
}),
)
const init = fetchMock.mock.calls[0][1] as { headers: Headers }
expect(init.headers.get(SESSION_IDLE_EXEMPT_HEADER)).toBe('1')
})
it('omits idle-exempt header by default', async () => {
const { fetchNodes, SESSION_IDLE_EXEMPT_HEADER } = await import('./client')
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ nodes: [] }),
})
await fetchNodes(undefined, fetchMock)
const init = fetchMock.mock.calls[0][1] as { headers: Headers }
expect(init.headers.get(SESSION_IDLE_EXEMPT_HEADER)).toBeNull()
})
it('creates a node with generate options', async () => {
const { createNode } = await import('./client')
const fetchMock = vi.fn().mockResolvedValue({
@@ -621,4 +705,219 @@ describe('nodes API', () => {
}),
)
})
it('posts to the health-check endpoint', async () => {
const { runNodeHealthCheck } = await import('./client')
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
node: {
id: '11111111-2222-4333-8444-555555555555',
health_ok: true,
health_message: 'ping ok (icmp); ssh ok',
},
}),
})
const result = await runNodeHealthCheck(
'11111111-2222-4333-8444-555555555555',
fetchMock,
)
expect(result.node.health_ok).toBe(true)
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:8080/api/v1/nodes/11111111-2222-4333-8444-555555555555/health-check',
expect.objectContaining({
method: 'POST',
credentials: 'include',
}),
)
})
it('patches node health interval', async () => {
const { patchNode } = await import('./client')
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
node: {
id: '11111111-2222-4333-8444-555555555555',
health_check_interval_seconds: 300,
},
}),
})
const result = await patchNode(
'11111111-2222-4333-8444-555555555555',
{ health_check_interval_seconds: 300 },
fetchMock,
)
expect(result.node.health_check_interval_seconds).toBe(300)
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:8080/api/v1/nodes/11111111-2222-4333-8444-555555555555',
expect.objectContaining({
method: 'PATCH',
credentials: 'include',
body: JSON.stringify({ health_check_interval_seconds: 300 }),
}),
)
})
})
describe('actions API', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('fetches actions', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
actions: [
{
id: 'a1',
name: 'Uptime',
description: 'uptime',
kind: 'shell',
body: 'uptime',
env: [],
builtin: true,
created_at: '2026-01-01T00:00:00.000Z',
updated_at: '2026-01-01T00:00:00.000Z',
},
],
}),
})
const payload = await fetchActions(fetchMock)
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:8080/api/v1/actions',
expect.objectContaining({ credentials: 'include' }),
)
expect(payload.actions).toHaveLength(1)
expect(payload.actions[0]?.name).toBe('Uptime')
})
it('creates an action', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ actions: [] }),
})
await createAction(
{
name: 'Echo',
description: 'echo ip',
kind: 'shell',
body: 'echo {{node.ip}}',
env: [{ name: 'APP_HOME', value: '/opt/app' }],
},
fetchMock,
)
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:8080/api/v1/actions',
expect.objectContaining({
method: 'POST',
credentials: 'include',
body: JSON.stringify({
name: 'Echo',
description: 'echo ip',
kind: 'shell',
body: 'echo {{node.ip}}',
env: [{ name: 'APP_HOME', value: '/opt/app' }],
}),
}),
)
})
it('patches and deletes an action', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ actions: [] }),
})
await patchAction('custom-1', { name: 'Renamed', body: 'echo hi' }, fetchMock)
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:8080/api/v1/actions?id=custom-1',
expect.objectContaining({
method: 'PATCH',
body: JSON.stringify({ name: 'Renamed', body: 'echo hi' }),
}),
)
await deleteAction('custom-1', fetchMock)
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:8080/api/v1/actions?id=custom-1',
expect.objectContaining({ method: 'DELETE' }),
)
})
})
describe('session idle redirect', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('reloads to login when an authenticated call returns session_idle', async () => {
const assign = vi.fn()
vi.stubGlobal('location', {
pathname: '/configuration/security',
assign,
})
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: 401,
clone: () => ({
json: async () => ({ error: 'session_idle' }),
}),
json: async () => ({ error: 'session_idle' }),
})
await expect(fetchStatus(fetchMock)).rejects.toThrow()
expect(assign).toHaveBeenCalledWith('/login?reason=idle')
})
it('does not redirect on login 401', async () => {
const assign = vi.fn()
vi.stubGlobal('location', {
pathname: '/login',
assign,
})
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: 401,
clone: () => ({
json: async () => ({ error: 'invalid credentials' }),
}),
json: async () => ({ error: 'invalid credentials' }),
})
await expect(login('Admin', 'wrong', '', fetchMock)).rejects.toThrow(
'invalid credentials',
)
expect(assign).not.toHaveBeenCalled()
})
it('does not redirect for generic authentication required', async () => {
const assign = vi.fn()
vi.stubGlobal('location', {
pathname: '/',
assign,
})
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: 401,
clone: () => ({
json: async () => ({ error: 'authentication required' }),
}),
json: async () => ({ error: 'authentication required' }),
})
await expect(fetchStatus(fetchMock)).rejects.toThrow()
expect(assign).not.toHaveBeenCalled()
})
})
+602 -3
View File
@@ -154,20 +154,61 @@ async function readErrorMessage(response: Response): Promise<string> {
return `request failed: ${response.status}`
}
const SESSION_IDLE_ERROR = 'session_idle'
const IDLE_LOGIN_PATH = '/login?reason=idle'
export const SESSION_IDLE_EXEMPT_HEADER = 'X-Session-Idle-Exempt'
export type ApiFetchOptions = {
idleExempt?: boolean
}
function shouldRedirectOnSessionIdle(path: string): boolean {
if (path === '/api/v1/auth/login') {
return false
}
if (typeof window === 'undefined') {
return false
}
return window.location.pathname !== '/login'
}
async function redirectIfSessionIdle(
response: Response,
path: string,
): Promise<void> {
if (response.status !== 401 || !shouldRedirectOnSessionIdle(path)) {
return
}
try {
const payload = (await response.clone().json()) as { error?: string }
if (payload.error === SESSION_IDLE_ERROR) {
window.location.assign(IDLE_LOGIN_PATH)
}
} catch {
// ignore non-JSON bodies
}
}
async function apiFetch(
path: string,
init: RequestInit = {},
fetchImpl: typeof fetch = fetch,
options: ApiFetchOptions = {},
): Promise<Response> {
const headers = new Headers(init.headers)
if (init.body && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
return fetchImpl(`${getApiBaseUrl()}${path}`, {
if (options.idleExempt) {
headers.set(SESSION_IDLE_EXEMPT_HEADER, '1')
}
const response = await fetchImpl(`${getApiBaseUrl()}${path}`, {
...init,
headers,
credentials: 'include',
})
await redirectIfSessionIdle(response, path)
return response
}
export async function fetchHealth(
@@ -617,6 +658,12 @@ export type Node = {
public_key: string
key_algo: string
created_at: string
health_check_interval_seconds?: number
health_last_checked_at?: string | null
health_ping_ok?: boolean | null
health_ssh_ok?: boolean | null
health_ok?: boolean | null
health_message?: string
}
export type NodesResponse = {
@@ -648,9 +695,10 @@ export type CreateNodeRequest = {
export async function fetchNodes(
kind?: NodeKind,
fetchImpl: typeof fetch = fetch,
options: ApiFetchOptions = {},
): Promise<NodesResponse> {
const query = kind ? `?kind=${encodeURIComponent(kind)}` : ''
const response = await apiFetch(`/api/v1/nodes${query}`, {}, fetchImpl)
const response = await apiFetch(`/api/v1/nodes${query}`, {}, fetchImpl, options)
if (!response.ok) {
throw new Error(await readErrorMessage(response))
}
@@ -732,7 +780,70 @@ export async function testNodeSSH(
return (await response.json()) as NodeSSHTestResponse
}
export type NodeAuditAction = 'create' | 'delete' | 'test_ssh' | 'update'
export type PatchNodeRequest = {
health_check_interval_seconds: number
}
export async function patchNode(
id: string,
payload: PatchNodeRequest,
fetchImpl: typeof fetch = fetch,
): Promise<NodeResponse> {
const response = await apiFetch(
`/api/v1/nodes/${encodeURIComponent(id)}`,
{
method: 'PATCH',
body: JSON.stringify(payload),
},
fetchImpl,
)
if (!response.ok) {
throw new Error(await readErrorMessage(response))
}
return (await response.json()) as NodeResponse
}
export async function runNodeHealthCheck(
id: string,
fetchImpl: typeof fetch = fetch,
): Promise<NodeResponse> {
const response = await apiFetch(
`/api/v1/nodes/${encodeURIComponent(id)}/health-check`,
{ method: 'POST' },
fetchImpl,
)
if (!response.ok) {
throw new Error(await readErrorMessage(response))
}
return (await response.json()) as NodeResponse
}
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' | 'health_check' | 'update'
export type NodeAuditEvent = {
id: string
@@ -760,3 +871,491 @@ export async function fetchNodeLogs(
}
return (await response.json()) as NodeLogsResponse
}
export type AuthAuditOutcome = 'success' | 'failure'
export type AuthAuditCategory = 'auth' | 'user' | 'group' | 'self'
export type AuthAuditEvent = {
id: string
at: string
action: string
outcome: AuthAuditOutcome
category: AuthAuditCategory
actor: string
target?: string
detail?: string
}
export type AuthLogsResponse = {
events: ReadonlyArray<AuthAuditEvent>
}
export async function fetchAuthLogs(
fetchImpl: typeof fetch = fetch,
options: ApiFetchOptions = {},
): Promise<AuthLogsResponse> {
const response = await apiFetch('/api/v1/auth-logs', {}, fetchImpl, options)
if (!response.ok) {
throw new Error(await readErrorMessage(response))
}
return (await response.json()) as AuthLogsResponse
}
export type ActionKind = 'shell' | 'script'
export type ActionEnvVar = {
name: string
value: string
}
export type Action = {
id: string
name: string
description: string
kind: ActionKind
body: string
env: ReadonlyArray<ActionEnvVar>
requires_sudo: boolean
builtin: boolean
created_at: string
updated_at: string
}
export type ActionsResponse = {
actions: ReadonlyArray<Action>
}
export type CreateActionRequest = {
name: string
description: string
kind: ActionKind
body: string
env: ReadonlyArray<ActionEnvVar>
requires_sudo?: boolean
}
export type PatchActionRequest = {
name?: string
description?: string
body?: string
env?: ReadonlyArray<ActionEnvVar>
requires_sudo?: boolean
}
export async function fetchActions(
fetchImpl: typeof fetch = fetch,
): Promise<ActionsResponse> {
const response = await apiFetch('/api/v1/actions', {}, fetchImpl)
if (!response.ok) {
throw new Error(await readErrorMessage(response))
}
return (await response.json()) as ActionsResponse
}
export async function createAction(
action: CreateActionRequest,
fetchImpl: typeof fetch = fetch,
): Promise<ActionsResponse> {
const response = await apiFetch(
'/api/v1/actions',
{
method: 'POST',
body: JSON.stringify(action),
},
fetchImpl,
)
if (!response.ok) {
throw new Error(await readErrorMessage(response))
}
return (await response.json()) as ActionsResponse
}
export async function patchAction(
id: string,
patch: PatchActionRequest,
fetchImpl: typeof fetch = fetch,
): Promise<ActionsResponse> {
const response = await apiFetch(
`/api/v1/actions?id=${encodeURIComponent(id)}`,
{
method: 'PATCH',
body: JSON.stringify(patch),
},
fetchImpl,
)
if (!response.ok) {
throw new Error(await readErrorMessage(response))
}
return (await response.json()) as ActionsResponse
}
export async function deleteAction(
id: string,
fetchImpl: typeof fetch = fetch,
): Promise<ActionsResponse> {
const response = await apiFetch(
`/api/v1/actions?id=${encodeURIComponent(id)}`,
{ method: 'DELETE' },
fetchImpl,
)
if (!response.ok) {
throw new Error(await readErrorMessage(response))
}
return (await response.json()) as ActionsResponse
}
export type ActionItemSource = 'library' | 'local'
export type ActionGroupScheduleKind = 'interval' | 'times'
export type ActionRunTrigger = 'manual' | 'schedule'
export type ActionGroupTimeSpec = {
time: string
days_of_week?: ReadonlyArray<number>
}
export type ActionGroupSchedule = {
enabled: boolean
kind: ActionGroupScheduleKind
every_seconds?: number
times?: ReadonlyArray<ActionGroupTimeSpec>
}
export type NodeActionItem = {
id: string
source: ActionItemSource
library_action_id?: string
name?: string
description?: string
kind?: ActionKind
body?: string
env?: ReadonlyArray<ActionEnvVar>
requires_sudo?: boolean
set_variable?: string
run_if?: string
}
export type NodeActionGroup = {
id: string
node_id: string
name: string
schedule: ActionGroupSchedule
items: ReadonlyArray<NodeActionItem>
last_run_at?: string
created_at: string
updated_at: string
}
export type ActionGroupsResponse = {
groups: ReadonlyArray<NodeActionGroup>
}
export type ActionGroupResponse = {
group: NodeActionGroup
}
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
}
export type ActionGroupRunRecord = {
id: string
node_id: string
group_id: string
group_name: string
trigger: ActionRunTrigger
actor?: string
started_at: string
finished_at: string
actions: ReadonlyArray<ActionItemRunResult>
}
export type ActionRunResponse = {
run: ActionGroupRunRecord
}
export type ActionLogFileInfo = {
filename: string
group_name: string
group_id?: string
mod_time: string
size_bytes: number
}
export type ActionLogFile = {
node_id: string
group_id: string
group_name: string
runs: ReadonlyArray<ActionGroupRunRecord>
}
export type ActionLogsListResponse = {
files: ReadonlyArray<ActionLogFileInfo>
}
export type ActionLogFileResponse = {
log: ActionLogFile
}
export type CreateActionGroupRequest = {
name: string
schedule?: ActionGroupSchedule
}
export type PatchActionGroupRequest = {
name?: string
schedule?: ActionGroupSchedule
}
export type CreateActionGroupItemRequest = {
source: ActionItemSource
library_action_id?: string
name?: string
description?: string
kind?: ActionKind
body?: string
env?: ReadonlyArray<ActionEnvVar>
requires_sudo?: boolean
set_variable?: string
run_if?: string
}
export type PatchActionGroupItemRequest = {
name?: string
description?: string
kind?: ActionKind
body?: string
env?: ReadonlyArray<ActionEnvVar>
requires_sudo?: boolean
set_variable?: string
run_if?: string
}
export async function fetchActionGroups(
nodeId: string,
fetchImpl: typeof fetch = fetch,
): Promise<ActionGroupsResponse> {
const response = await apiFetch(
`/api/v1/nodes/${encodeURIComponent(nodeId)}/action-groups`,
{},
fetchImpl,
)
if (!response.ok) {
throw new Error(await readErrorMessage(response))
}
return (await response.json()) as ActionGroupsResponse
}
export async function createActionGroup(
nodeId: string,
body: CreateActionGroupRequest,
fetchImpl: typeof fetch = fetch,
): Promise<ActionGroupResponse> {
const response = await apiFetch(
`/api/v1/nodes/${encodeURIComponent(nodeId)}/action-groups`,
{ method: 'POST', body: JSON.stringify(body) },
fetchImpl,
)
if (!response.ok) {
throw new Error(await readErrorMessage(response))
}
return (await response.json()) as ActionGroupResponse
}
export async function patchActionGroup(
nodeId: string,
groupId: string,
body: PatchActionGroupRequest,
fetchImpl: typeof fetch = fetch,
): Promise<ActionGroupResponse> {
const response = await apiFetch(
`/api/v1/nodes/${encodeURIComponent(nodeId)}/action-groups/${encodeURIComponent(groupId)}`,
{ method: 'PATCH', body: JSON.stringify(body) },
fetchImpl,
)
if (!response.ok) {
throw new Error(await readErrorMessage(response))
}
return (await response.json()) as ActionGroupResponse
}
export async function deleteActionGroup(
nodeId: string,
groupId: string,
fetchImpl: typeof fetch = fetch,
): Promise<ActionGroupsResponse> {
const response = await apiFetch(
`/api/v1/nodes/${encodeURIComponent(nodeId)}/action-groups/${encodeURIComponent(groupId)}`,
{ method: 'DELETE' },
fetchImpl,
)
if (!response.ok) {
throw new Error(await readErrorMessage(response))
}
return (await response.json()) as ActionGroupsResponse
}
export async function createActionGroupItem(
nodeId: string,
groupId: string,
body: CreateActionGroupItemRequest,
fetchImpl: typeof fetch = fetch,
): Promise<ActionGroupResponse> {
const response = await apiFetch(
`/api/v1/nodes/${encodeURIComponent(nodeId)}/action-groups/${encodeURIComponent(groupId)}/items`,
{ method: 'POST', body: JSON.stringify(body) },
fetchImpl,
)
if (!response.ok) {
throw new Error(await readErrorMessage(response))
}
return (await response.json()) as ActionGroupResponse
}
export async function patchActionGroupItem(
nodeId: string,
groupId: string,
itemId: string,
body: PatchActionGroupItemRequest,
fetchImpl: typeof fetch = fetch,
): Promise<ActionGroupResponse> {
const response = await apiFetch(
`/api/v1/nodes/${encodeURIComponent(nodeId)}/action-groups/${encodeURIComponent(groupId)}/items/${encodeURIComponent(itemId)}`,
{ method: 'PATCH', body: JSON.stringify(body) },
fetchImpl,
)
if (!response.ok) {
throw new Error(await readErrorMessage(response))
}
return (await response.json()) as ActionGroupResponse
}
export async function cloneActionGroupItem(
nodeId: string,
groupId: string,
itemId: string,
fetchImpl: typeof fetch = fetch,
): Promise<ActionGroupResponse> {
const response = await apiFetch(
`/api/v1/nodes/${encodeURIComponent(nodeId)}/action-groups/${encodeURIComponent(groupId)}/items/${encodeURIComponent(itemId)}/clone`,
{ method: 'POST' },
fetchImpl,
)
if (!response.ok) {
throw new Error(await readErrorMessage(response))
}
return (await response.json()) as ActionGroupResponse
}
export async function deleteActionGroupItem(
nodeId: string,
groupId: string,
itemId: string,
fetchImpl: typeof fetch = fetch,
): Promise<ActionGroupResponse> {
const response = await apiFetch(
`/api/v1/nodes/${encodeURIComponent(nodeId)}/action-groups/${encodeURIComponent(groupId)}/items/${encodeURIComponent(itemId)}`,
{ method: 'DELETE' },
fetchImpl,
)
if (!response.ok) {
throw new Error(await readErrorMessage(response))
}
return (await response.json()) as ActionGroupResponse
}
export async function reorderActionGroupItems(
nodeId: string,
groupId: string,
itemIds: ReadonlyArray<string>,
fetchImpl: typeof fetch = fetch,
): Promise<ActionGroupResponse> {
const response = await apiFetch(
`/api/v1/nodes/${encodeURIComponent(nodeId)}/action-groups/${encodeURIComponent(groupId)}/items/order`,
{ method: 'PUT', body: JSON.stringify({ item_ids: itemIds }) },
fetchImpl,
)
if (!response.ok) {
throw new Error(await readErrorMessage(response))
}
return (await response.json()) as ActionGroupResponse
}
export async function runActionGroup(
nodeId: string,
groupId: string,
fetchImpl: typeof fetch = fetch,
): Promise<ActionRunResponse> {
const response = await apiFetch(
`/api/v1/nodes/${encodeURIComponent(nodeId)}/action-groups/${encodeURIComponent(groupId)}/run`,
{ method: 'POST' },
fetchImpl,
)
if (!response.ok) {
throw new Error(await readErrorMessage(response))
}
return (await response.json()) as ActionRunResponse
}
export async function runActionGroupItem(
nodeId: string,
groupId: string,
itemId: string,
fetchImpl: typeof fetch = fetch,
): Promise<ActionRunResponse> {
const response = await apiFetch(
`/api/v1/nodes/${encodeURIComponent(nodeId)}/action-groups/${encodeURIComponent(groupId)}/items/${encodeURIComponent(itemId)}/run`,
{ method: 'POST' },
fetchImpl,
)
if (!response.ok) {
throw new Error(await readErrorMessage(response))
}
return (await response.json()) as ActionRunResponse
}
export async function fetchActionLogs(
nodeId: string,
fetchImpl: typeof fetch = fetch,
): Promise<ActionLogsListResponse> {
const response = await apiFetch(
`/api/v1/nodes/${encodeURIComponent(nodeId)}/action-logs`,
{},
fetchImpl,
)
if (!response.ok) {
throw new Error(await readErrorMessage(response))
}
return (await response.json()) as ActionLogsListResponse
}
export async function fetchActionLogFile(
nodeId: string,
filename: string,
fetchImpl: typeof fetch = fetch,
): Promise<ActionLogFileResponse> {
const response = await apiFetch(
`/api/v1/nodes/${encodeURIComponent(nodeId)}/action-logs/${encodeURIComponent(filename)}`,
{},
fetchImpl,
)
if (!response.ok) {
throw new Error(await readErrorMessage(response))
}
return (await response.json()) as ActionLogFileResponse
}
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { documentTitleFor, SECTION_TITLES } from './documentTitle'
describe('documentTitleFor', () => {
it('returns the app name alone while loading', () => {
expect(documentTitleFor('loading')).toBe('ClusterCanvas')
})
it('formats category titles for main sections', () => {
expect(documentTitleFor('overview')).toBe('ClusterCanvas - Overview')
expect(documentTitleFor('vms')).toBe('ClusterCanvas - Virtual Machines')
expect(documentTitleFor('docker')).toBe('ClusterCanvas - Docker')
expect(documentTitleFor('containers')).toBe('ClusterCanvas - Containers')
expect(documentTitleFor('logs')).toBe('ClusterCanvas - Logs')
expect(documentTitleFor('profile')).toBe('ClusterCanvas - Profile')
expect(documentTitleFor('configuration')).toBe(
'ClusterCanvas - Configuration',
)
})
it('formats titles for setup and sign-in', () => {
expect(documentTitleFor('setup')).toBe('ClusterCanvas - Setup')
expect(documentTitleFor('login')).toBe('ClusterCanvas - Sign in')
})
it('uses SECTION_TITLES for every section id', () => {
for (const section of Object.keys(SECTION_TITLES) as Array<
keyof typeof SECTION_TITLES
>) {
expect(documentTitleFor(section)).toBe(
`ClusterCanvas - ${SECTION_TITLES[section]}`,
)
}
})
})
+22
View File
@@ -0,0 +1,22 @@
import type { SectionId } from './navigation'
export const SECTION_TITLES: Record<SectionId, string> = {
overview: 'Overview',
vms: 'Virtual Machines',
docker: 'Docker',
containers: 'Containers',
logs: 'Logs',
profile: 'Profile',
configuration: 'Configuration',
setup: 'Setup',
login: 'Sign in',
}
const APP_TITLE = 'ClusterCanvas'
export function documentTitleFor(section: SectionId | 'loading'): string {
if (section === 'loading') {
return APP_TITLE
}
return `${APP_TITLE} - ${SECTION_TITLES[section]}`
}
+8
View File
@@ -12,6 +12,10 @@
--ctp-subtext0: #6c6f85;
--ctp-subtext1: #5c5f77;
--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));
color-scheme: light;
@@ -30,6 +34,10 @@
--ctp-subtext0: #a6adc8;
--ctp-subtext1: #bac2de;
--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));
color-scheme: dark;
+205
View File
@@ -0,0 +1,205 @@
import { describe, expect, it } from 'vitest'
import type { AuthAuditEvent, NodeAuditEvent } from './api/client'
import {
eventMatchesSearch,
filterAuthEvents,
filterNodeEvents,
isWithinDateRange,
pageRangeLabel,
paginateItems,
resolveDateRangeBounds,
sortAuthEvents,
sortNodeEvents,
} from './logsFiltering'
const nodeEvents: NodeAuditEvent[] = [
{
id: '1',
at: '2026-07-18T12:00:00.000Z',
action: 'create',
actor: 'Admin',
node_id: 'n1',
node_name: 'alpha',
node_kind: 'container',
detail: 'created',
},
{
id: '2',
at: '2026-07-17T12:00:00.000Z',
action: 'delete',
actor: 'bob',
node_id: 'n2',
node_name: 'beta-vm',
node_kind: 'vm',
detail: 'removed',
},
{
id: '3',
at: '2026-07-10T12:00:00.000Z',
action: 'test_ssh',
actor: 'Admin',
node_id: 'n3',
node_name: 'gamma',
node_kind: 'docker',
},
]
const authEvents: AuthAuditEvent[] = [
{
id: 'a1',
at: '2026-07-18T15:00:00.000Z',
action: 'login',
outcome: 'failure',
category: 'auth',
actor: 'eve',
detail: 'invalid credentials',
},
{
id: 'a2',
at: '2026-07-18T14:00:00.000Z',
action: 'login',
outcome: 'success',
category: 'auth',
actor: 'Admin',
},
{
id: 'a3',
at: '2026-07-17T14:00:00.000Z',
action: 'user_create',
outcome: 'success',
category: 'user',
actor: 'Admin',
target: 'alice',
},
]
describe('eventMatchesSearch', () => {
it('matches any field case-insensitively', () => {
expect(eventMatchesSearch(['Admin', 'alpha'], 'ADM')).toBe(true)
expect(eventMatchesSearch(['Admin', 'alpha'], 'zzz')).toBe(false)
expect(eventMatchesSearch(['Admin'], ' ')).toBe(true)
})
})
describe('resolveDateRangeBounds', () => {
const now = new Date('2026-07-18T16:00:00.000Z')
it('returns null bounds for all', () => {
expect(resolveDateRangeBounds('all', '', '', now)).toEqual({
from: null,
to: null,
})
})
it('resolves today to local midnight', () => {
const { from, to } = resolveDateRangeBounds('today', '', '', now)
expect(to).toBeNull()
expect(from).not.toBeNull()
expect(from!.getHours()).toBe(0)
expect(from!.getMinutes()).toBe(0)
})
it('parses custom range', () => {
const { from, to } = resolveDateRangeBounds(
'custom',
'2026-07-01T00:00',
'2026-07-20T23:59',
now,
)
expect(from).not.toBeNull()
expect(to).not.toBeNull()
})
})
describe('isWithinDateRange', () => {
it('filters by from and to', () => {
const from = new Date('2026-07-17T00:00:00.000Z')
const to = new Date('2026-07-18T00:00:00.000Z')
expect(isWithinDateRange('2026-07-17T12:00:00.000Z', from, to)).toBe(true)
expect(isWithinDateRange('2026-07-16T12:00:00.000Z', from, to)).toBe(false)
expect(isWithinDateRange('2026-07-19T12:00:00.000Z', from, to)).toBe(false)
})
})
describe('filterNodeEvents', () => {
it('filters by kind, action, and search', () => {
const filtered = filterNodeEvents(nodeEvents, {
search: 'beta',
kind: 'vm',
action: 'delete',
dateFrom: null,
dateTo: null,
})
expect(filtered).toHaveLength(1)
expect(filtered[0]?.id).toBe('2')
})
})
describe('sortNodeEvents', () => {
it('sorts by when descending by default key', () => {
const sorted = sortNodeEvents(nodeEvents, 'at', 'desc')
expect(sorted.map((event) => event.id)).toEqual(['1', '2', '3'])
})
it('sorts by actor ascending', () => {
const sorted = sortNodeEvents(nodeEvents, 'actor', 'asc')
expect(sorted[0]?.actor).toBe('Admin')
expect(sorted[sorted.length - 1]?.actor).toBe('bob')
})
})
describe('filterAuthEvents', () => {
it('filters login failures', () => {
const filtered = filterAuthEvents(authEvents, {
search: '',
outcome: 'all',
category: 'all',
actionFilter: 'login_failures',
dateFrom: null,
dateTo: null,
})
expect(filtered).toHaveLength(1)
expect(filtered[0]?.id).toBe('a1')
})
it('filters by outcome and category', () => {
const filtered = filterAuthEvents(authEvents, {
search: 'alice',
outcome: 'success',
category: 'user',
actionFilter: 'all',
dateFrom: null,
dateTo: null,
})
expect(filtered).toHaveLength(1)
expect(filtered[0]?.target).toBe('alice')
})
})
describe('sortAuthEvents', () => {
it('sorts newest first by at', () => {
const sorted = sortAuthEvents(authEvents, 'at', 'desc')
expect(sorted.map((event) => event.id)).toEqual(['a1', 'a2', 'a3'])
})
})
describe('paginateItems', () => {
it('slices pages and clamps page number', () => {
const items = [1, 2, 3, 4, 5]
expect(paginateItems(items, 1, 2)).toEqual({
pageItems: [1, 2],
totalPages: 3,
safePage: 1,
})
expect(paginateItems(items, 99, 2).safePage).toBe(3)
expect(paginateItems(items, 99, 2).pageItems).toEqual([5])
})
})
describe('pageRangeLabel', () => {
it('formats the visible range', () => {
expect(pageRangeLabel(0, 1, 25)).toBe('Showing 0 of 0')
expect(pageRangeLabel(40, 2, 25)).toBe('Showing 2640 of 40')
})
})
+306
View File
@@ -0,0 +1,306 @@
import type {
AuthAuditEvent,
NodeAuditAction,
NodeAuditEvent,
NodeKind,
} from './api/client'
export type DateRangePreset = 'all' | 'today' | '7d' | '30d' | 'custom'
export type SortDirection = 'asc' | 'desc'
export type NodeLogsSortKey =
| 'at'
| 'action'
| 'actor'
| 'node'
| 'kind'
| 'detail'
export type AuthLogsSortKey =
| 'at'
| 'action'
| 'actor'
| 'target'
| 'outcome'
| 'category'
| 'detail'
export type AuthOutcomeFilter = 'all' | 'failure' | 'success'
export type AuthCategoryFilter = 'all' | AuthAuditEvent['category']
export type AuthActionQuickFilter = 'all' | 'login_failures' | string
function normalizeSearchText(value: string): string {
return value.trim().toLowerCase()
}
export function eventMatchesSearch(
fields: ReadonlyArray<string | undefined | null>,
query: string,
): boolean {
const normalizedQuery = normalizeSearchText(query)
if (normalizedQuery === '') {
return true
}
return fields.some((field) =>
normalizeSearchText(field ?? '').includes(normalizedQuery),
)
}
export function startOfLocalDay(reference: Date = new Date()): Date {
return new Date(
reference.getFullYear(),
reference.getMonth(),
reference.getDate(),
)
}
export function resolveDateRangeBounds(
preset: DateRangePreset,
customFrom: string,
customTo: string,
now: Date = new Date(),
): { from: Date | null; to: Date | null } {
if (preset === 'all') {
return { from: null, to: null }
}
if (preset === 'today') {
return { from: startOfLocalDay(now), to: null }
}
if (preset === '7d') {
const from = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
return { from, to: null }
}
if (preset === '30d') {
const from = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
return { from, to: null }
}
const fromParsed = customFrom ? Date.parse(customFrom) : Number.NaN
const toParsed = customTo ? Date.parse(customTo) : Number.NaN
return {
from: Number.isNaN(fromParsed) ? null : new Date(fromParsed),
to: Number.isNaN(toParsed) ? null : new Date(toParsed),
}
}
export function isWithinDateRange(
at: string,
from: Date | null,
to: Date | null,
): boolean {
const timestamp = Date.parse(at)
if (Number.isNaN(timestamp)) {
return false
}
if (from !== null && timestamp < from.getTime()) {
return false
}
if (to !== null && timestamp > to.getTime()) {
return false
}
return true
}
function compareStrings(left: string, right: string): number {
return left.localeCompare(right, undefined, { sensitivity: 'base' })
}
function compareTimestamps(left: string, right: string): number {
const leftTime = Date.parse(left)
const rightTime = Date.parse(right)
if (Number.isNaN(leftTime) && Number.isNaN(rightTime)) {
return 0
}
if (Number.isNaN(leftTime)) {
return 1
}
if (Number.isNaN(rightTime)) {
return -1
}
return leftTime - rightTime
}
function applySortDirection(comparison: number, direction: SortDirection): number {
return direction === 'asc' ? comparison : -comparison
}
export function filterNodeEvents(
events: ReadonlyArray<NodeAuditEvent>,
options: {
search: string
kind: 'all' | NodeKind
action: 'all' | NodeAuditAction
dateFrom: Date | null
dateTo: Date | null
},
): NodeAuditEvent[] {
return events.filter((event) => {
if (options.kind !== 'all' && event.node_kind !== options.kind) {
return false
}
if (options.action !== 'all' && event.action !== options.action) {
return false
}
if (!isWithinDateRange(event.at, options.dateFrom, options.dateTo)) {
return false
}
return eventMatchesSearch(
[
event.at,
event.action,
event.actor,
event.node_name,
event.node_id,
event.node_kind,
event.detail,
],
options.search,
)
})
}
export function sortNodeEvents(
events: ReadonlyArray<NodeAuditEvent>,
sortKey: NodeLogsSortKey,
direction: SortDirection,
): NodeAuditEvent[] {
const sorted = [...events]
sorted.sort((left, right) => {
let comparison = 0
switch (sortKey) {
case 'at':
comparison = compareTimestamps(left.at, right.at)
break
case 'action':
comparison = compareStrings(left.action, right.action)
break
case 'actor':
comparison = compareStrings(left.actor, right.actor)
break
case 'node':
comparison = compareStrings(left.node_name, right.node_name)
break
case 'kind':
comparison = compareStrings(left.node_kind, right.node_kind)
break
case 'detail':
comparison = compareStrings(left.detail ?? '', right.detail ?? '')
break
}
return applySortDirection(comparison, direction)
})
return sorted
}
export function filterAuthEvents(
events: ReadonlyArray<AuthAuditEvent>,
options: {
search: string
outcome: AuthOutcomeFilter
category: AuthCategoryFilter
actionFilter: AuthActionQuickFilter
dateFrom: Date | null
dateTo: Date | null
},
): AuthAuditEvent[] {
return events.filter((event) => {
if (options.outcome !== 'all' && event.outcome !== options.outcome) {
return false
}
if (options.category !== 'all' && event.category !== options.category) {
return false
}
if (options.actionFilter === 'login_failures') {
if (event.action !== 'login' || event.outcome !== 'failure') {
return false
}
} else if (
options.actionFilter !== 'all' &&
event.action !== options.actionFilter
) {
return false
}
if (!isWithinDateRange(event.at, options.dateFrom, options.dateTo)) {
return false
}
return eventMatchesSearch(
[
event.at,
event.action,
event.outcome,
event.category,
event.actor,
event.target,
event.detail,
],
options.search,
)
})
}
export function sortAuthEvents(
events: ReadonlyArray<AuthAuditEvent>,
sortKey: AuthLogsSortKey,
direction: SortDirection,
): AuthAuditEvent[] {
const sorted = [...events]
sorted.sort((left, right) => {
let comparison = 0
switch (sortKey) {
case 'at':
comparison = compareTimestamps(left.at, right.at)
break
case 'action':
comparison = compareStrings(left.action, right.action)
break
case 'actor':
comparison = compareStrings(left.actor, right.actor)
break
case 'target':
comparison = compareStrings(left.target ?? '', right.target ?? '')
break
case 'outcome':
comparison = compareStrings(left.outcome, right.outcome)
break
case 'category':
comparison = compareStrings(left.category, right.category)
break
case 'detail':
comparison = compareStrings(left.detail ?? '', right.detail ?? '')
break
}
return applySortDirection(comparison, direction)
})
return sorted
}
export function paginateItems<T>(
items: ReadonlyArray<T>,
page: number,
pageSize: number,
): { pageItems: T[]; totalPages: number; safePage: number } {
const totalPages = Math.max(1, Math.ceil(items.length / pageSize))
const safePage = Math.min(Math.max(1, page), totalPages)
const startIndex = (safePage - 1) * pageSize
return {
pageItems: items.slice(startIndex, startIndex + pageSize),
totalPages,
safePage,
}
}
export function pageRangeLabel(
totalCount: number,
page: number,
pageSize: number,
): string {
if (totalCount === 0) {
return 'Showing 0 of 0'
}
const start = (page - 1) * pageSize + 1
const end = Math.min(page * pageSize, totalCount)
return `Showing ${start}${end} of ${totalCount}`
}
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import {
DEFAULT_LOGS_PAGE_SIZE,
isLogsPageSize,
LOGS_PAGE_SIZE_STORAGE_KEY,
readLogsPageSize,
writeLogsPageSize,
} from './logsPreferences'
describe('logsPreferences', () => {
it('defaults to 25', () => {
const storage = {
getItem: () => null,
setItem: () => undefined,
}
expect(readLogsPageSize(storage)).toBe(DEFAULT_LOGS_PAGE_SIZE)
})
it('reads and writes a valid page size', () => {
const values = new Map<string, string>()
const storage = {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => {
values.set(key, value)
},
}
writeLogsPageSize(100, storage)
expect(values.get(LOGS_PAGE_SIZE_STORAGE_KEY)).toBe('100')
expect(readLogsPageSize(storage)).toBe(100)
})
it('rejects invalid sizes', () => {
expect(isLogsPageSize(25)).toBe(true)
expect(isLogsPageSize(30)).toBe(false)
const storage = {
getItem: () => '9999',
setItem: () => undefined,
}
expect(readLogsPageSize(storage)).toBe(DEFAULT_LOGS_PAGE_SIZE)
})
})
+36
View File
@@ -0,0 +1,36 @@
export const LOGS_PAGE_SIZE_STORAGE_KEY = 'clustercanvas.logs.pageSize'
export const LOGS_PAGE_SIZE_OPTIONS = [
25, 50, 100, 250, 500, 1000,
] as const
export type LogsPageSize = (typeof LOGS_PAGE_SIZE_OPTIONS)[number]
export const DEFAULT_LOGS_PAGE_SIZE: LogsPageSize = 25
const VALID_PAGE_SIZES: ReadonlySet<number> = new Set(LOGS_PAGE_SIZE_OPTIONS)
export function isLogsPageSize(value: number): value is LogsPageSize {
return VALID_PAGE_SIZES.has(value)
}
export function readLogsPageSize(
storage: Pick<Storage, 'getItem'> = localStorage,
): LogsPageSize {
const storedValue = storage.getItem(LOGS_PAGE_SIZE_STORAGE_KEY)
if (storedValue === null) {
return DEFAULT_LOGS_PAGE_SIZE
}
const parsed = Number.parseInt(storedValue, 10)
if (Number.isFinite(parsed) && isLogsPageSize(parsed)) {
return parsed
}
return DEFAULT_LOGS_PAGE_SIZE
}
export function writeLogsPageSize(
pageSize: LogsPageSize,
storage: Pick<Storage, 'setItem'> = localStorage,
): void {
storage.setItem(LOGS_PAGE_SIZE_STORAGE_KEY, String(pageSize))
}
+37 -8
View File
@@ -17,8 +17,21 @@ describe('pathFor', () => {
expect(pathFor('configuration')).toBe('/configuration')
expect(pathFor('configuration', 'overview')).toBe('/configuration')
expect(pathFor('configuration', 'groups')).toBe('/configuration/groups')
expect(pathFor('configuration', 'actions')).toBe('/configuration/actions')
expect(pathFor('configuration', 'roles')).toBe('/configuration/roles')
})
it('maps node detail paths', () => {
expect(pathFor('containers', 'overview', 'overview', 'node-1')).toBe(
'/containers/node-1',
)
expect(
pathFor('containers', 'overview', 'overview', 'node-1', 'actions'),
).toBe('/containers/node-1/actions')
expect(pathFor('vms', 'overview', 'overview', 'abc', 'logs')).toBe(
'/vms/abc/logs',
)
})
})
describe('parseLocation', () => {
@@ -39,14 +52,25 @@ describe('parseLocation', () => {
'/configuration/users',
'/configuration/roles',
'/configuration/groups',
'/configuration/actions',
'/configuration/security',
'/configuration/integrations',
'/configuration/network',
'/configuration/advanced',
'/containers/node-1',
'/containers/node-1/actions',
'/containers/node-1/healthcheck',
'/vms/node-2/logs',
]) {
const location = parseLocation(path)
expect(
pathFor(location.section, location.configTab, location.resourceTab),
pathFor(
location.section,
location.configTab,
location.resourceTab,
location.nodeId,
location.nodeDetailTab,
),
).toBe(path === '/docker/overview' ? '/docker' : path)
}
})
@@ -56,21 +80,29 @@ describe('parseLocation', () => {
section: 'overview',
configTab: 'overview',
resourceTab: 'overview',
nodeId: null,
nodeDetailTab: 'overview',
})
expect(parseLocation('/configuration/unknown')).toEqual({
section: 'overview',
configTab: 'overview',
resourceTab: 'overview',
nodeId: null,
nodeDetailTab: 'overview',
})
expect(parseLocation('/vms/extra')).toEqual({
section: 'overview',
section: 'vms',
configTab: 'overview',
resourceTab: 'overview',
nodeId: 'extra',
nodeDetailTab: 'overview',
})
expect(parseLocation('/docker/extra')).toEqual({
expect(parseLocation('/docker/extra/nope')).toEqual({
section: 'overview',
configTab: 'overview',
resourceTab: 'overview',
nodeId: null,
nodeDetailTab: 'overview',
})
})
@@ -79,11 +111,8 @@ describe('parseLocation', () => {
section: 'configuration',
configTab: 'groups',
resourceTab: 'overview',
})
expect(parseLocation('/containers/add/')).toEqual({
section: 'containers',
configTab: 'overview',
resourceTab: 'add',
nodeId: null,
nodeDetailTab: 'overview',
})
})
})
+57 -10
View File
@@ -14,6 +14,7 @@ export type ConfigTabId =
| 'users'
| 'roles'
| 'groups'
| 'actions'
| 'security'
| 'integrations'
| 'network'
@@ -21,12 +22,16 @@ export type ConfigTabId =
export type ResourceTabId = 'overview' | 'security' | 'add'
export type NodeDetailTabId = 'overview' | 'healthcheck' | 'actions' | 'logs'
export type ResourceSectionId = 'containers' | 'vms' | 'docker'
export type AppLocation = {
section: SectionId
configTab: ConfigTabId
resourceTab: ResourceTabId
nodeId: string | null
nodeDetailTab: NodeDetailTabId
}
const CONFIG_TAB_IDS: ReadonlySet<string> = new Set([
@@ -34,6 +39,7 @@ const CONFIG_TAB_IDS: ReadonlySet<string> = new Set([
'users',
'roles',
'groups',
'actions',
'security',
'integrations',
'network',
@@ -46,6 +52,13 @@ const RESOURCE_TAB_IDS: ReadonlySet<string> = new Set([
'add',
])
const NODE_DETAIL_TAB_IDS: ReadonlySet<string> = new Set([
'overview',
'healthcheck',
'actions',
'logs',
])
const RESOURCE_SECTION_IDS: ReadonlySet<string> = new Set([
'containers',
'vms',
@@ -56,6 +69,8 @@ const DEFAULT_LOCATION: AppLocation = {
section: 'overview',
configTab: 'overview',
resourceTab: 'overview',
nodeId: null,
nodeDetailTab: 'overview',
}
function isConfigTabId(value: string): value is ConfigTabId {
@@ -66,6 +81,10 @@ function isResourceTabId(value: string): value is ResourceTabId {
return RESOURCE_TAB_IDS.has(value)
}
function isNodeDetailTabId(value: string): value is NodeDetailTabId {
return NODE_DETAIL_TAB_IDS.has(value)
}
export function isResourceSectionId(value: SectionId): value is ResourceSectionId {
return RESOURCE_SECTION_IDS.has(value)
}
@@ -79,31 +98,52 @@ export function parseLocation(pathname: string): AppLocation {
return { ...DEFAULT_LOCATION }
}
const [first, second] = segments
const [first, second, third] = segments
if (first === 'setup' && segments.length === 1) {
return { section: 'setup', configTab: 'overview', resourceTab: 'overview' }
return { ...DEFAULT_LOCATION, section: 'setup' }
}
if (first === 'login' && segments.length === 1) {
return { section: 'login', configTab: 'overview', resourceTab: 'overview' }
return { ...DEFAULT_LOCATION, section: 'login' }
}
if (first === 'profile' && segments.length === 1) {
return { section: 'profile', configTab: 'overview', resourceTab: 'overview' }
return { ...DEFAULT_LOCATION, section: 'profile' }
}
if (first === 'logs' && segments.length === 1) {
return { section: 'logs', configTab: 'overview', resourceTab: 'overview' }
return { ...DEFAULT_LOCATION, section: 'logs' }
}
if (RESOURCE_SECTION_IDS.has(first)) {
const section = first as ResourceSectionId
if (segments.length === 1) {
return { section, configTab: 'overview', resourceTab: 'overview' }
return { ...DEFAULT_LOCATION, section }
}
if (segments.length === 2 && isResourceTabId(second)) {
return { section, configTab: 'overview', resourceTab: second }
return {
...DEFAULT_LOCATION,
section,
resourceTab: second,
}
}
// /containers/:nodeId or /containers/:nodeId/:detailTab
if (segments.length === 2 && !isResourceTabId(second)) {
return {
...DEFAULT_LOCATION,
section,
nodeId: second,
nodeDetailTab: 'overview',
}
}
if (segments.length === 3 && !isResourceTabId(second) && isNodeDetailTabId(third)) {
return {
...DEFAULT_LOCATION,
section,
nodeId: second,
nodeDetailTab: third,
}
}
return { ...DEFAULT_LOCATION }
}
@@ -111,16 +151,15 @@ export function parseLocation(pathname: string): AppLocation {
if (first === 'configuration') {
if (segments.length === 1) {
return {
...DEFAULT_LOCATION,
section: 'configuration',
configTab: 'overview',
resourceTab: 'overview',
}
}
if (segments.length === 2 && isConfigTabId(second)) {
return {
...DEFAULT_LOCATION,
section: 'configuration',
configTab: second,
resourceTab: 'overview',
}
}
}
@@ -133,6 +172,8 @@ export function pathFor(
section: SectionId,
configTab: ConfigTabId = 'overview',
resourceTab: ResourceTabId = 'overview',
nodeId: string | null = null,
nodeDetailTab: NodeDetailTabId = 'overview',
): string {
if (section === 'overview') {
return '/'
@@ -150,6 +191,12 @@ export function pathFor(
return '/logs'
}
if (isResourceSectionId(section)) {
if (nodeId) {
if (nodeDetailTab === 'overview') {
return `/${section}/${nodeId}`
}
return `/${section}/${nodeId}/${nodeDetailTab}`
}
if (resourceTab === 'overview') {
return `/${section}`
}
+115
View File
@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest'
import type { AuthAuditEvent, Node } from './api/client'
import {
countAuthFailuresToday,
countNodeHealthErrorsBySection,
countNodesBySection,
} from './sidebarCounts'
function makeNode(kind: Node['kind'], id: string): Pick<Node, 'kind' | 'id'> {
return { kind, id }
}
describe('countNodesBySection', () => {
it('returns zeros for an empty list', () => {
expect(countNodesBySection([])).toEqual({
containers: 0,
docker: 0,
vms: 0,
})
})
it('counts nodes by kind into section ids', () => {
expect(
countNodesBySection([
makeNode('container', 'c1'),
makeNode('container', 'c2'),
makeNode('docker', 'd1'),
makeNode('vm', 'v1'),
makeNode('vm', 'v2'),
makeNode('vm', 'v3'),
]),
).toEqual({
containers: 2,
docker: 1,
vms: 3,
})
})
})
describe('countNodeHealthErrorsBySection', () => {
it('counts only nodes with health_ok false', () => {
expect(
countNodeHealthErrorsBySection([
{ kind: 'container', health_ok: false },
{ kind: 'container', health_ok: true },
{ kind: 'container', health_ok: null },
{ kind: 'container' },
{ kind: 'docker', health_ok: false },
{ kind: 'vm', health_ok: false },
{ kind: 'vm', health_ok: false },
{ kind: 'vm', health_ok: true },
]),
).toEqual({
containers: 1,
docker: 1,
vms: 2,
})
})
it('returns zeros when no unhealthy nodes', () => {
expect(
countNodeHealthErrorsBySection([
{ kind: 'container', health_ok: true },
{ kind: 'docker' },
{ kind: 'vm', health_ok: null },
]),
).toEqual({
containers: 0,
docker: 0,
vms: 0,
})
})
})
describe('countAuthFailuresToday', () => {
const now = new Date(2026, 6, 18, 15, 30, 0)
function makeEvent(
at: string,
outcome: AuthAuditEvent['outcome'],
action = 'login',
): Pick<AuthAuditEvent, 'at' | 'action' | 'outcome'> {
return { at, action, outcome }
}
it('counts only login failures within the local day', () => {
expect(
countAuthFailuresToday(
[
makeEvent('2026-07-18T08:00:00.000Z', 'failure'),
makeEvent('2026-07-18T10:00:00.000Z', 'success'),
makeEvent('2026-07-18T12:00:00.000Z', 'failure'),
makeEvent('2026-07-16T12:00:00.000Z', 'failure'),
makeEvent('2026-07-18T14:00:00.000Z', 'failure', 'logout'),
makeEvent('2026-07-18T15:00:00.000Z', 'failure', 'user_create'),
],
now,
),
).toBe(2)
})
it('returns zero when there are no login failures today', () => {
expect(
countAuthFailuresToday(
[
makeEvent('2026-07-18T10:00:00.000Z', 'success'),
makeEvent('2026-07-17T10:00:00.000Z', 'failure'),
makeEvent('2026-07-18T11:00:00.000Z', 'failure', 'reauth'),
],
now,
),
).toBe(0)
})
})
+60
View File
@@ -0,0 +1,60 @@
import type { AuthAuditEvent, Node, NodeKind } from './api/client'
import type { ResourceSectionId } from './navigation'
import { isWithinDateRange, startOfLocalDay } from './logsFiltering'
const KIND_TO_SECTION: Record<NodeKind, ResourceSectionId> = {
container: 'containers',
docker: 'docker',
vm: 'vms',
}
export type NodeSectionCounts = Record<ResourceSectionId, number>
export function countNodesBySection(
nodes: ReadonlyArray<Pick<Node, 'kind'>>,
): NodeSectionCounts {
const counts: NodeSectionCounts = {
containers: 0,
docker: 0,
vms: 0,
}
for (const node of nodes) {
counts[KIND_TO_SECTION[node.kind]] += 1
}
return counts
}
export function countNodeHealthErrorsBySection(
nodes: ReadonlyArray<Pick<Node, 'kind' | 'health_ok'>>,
): NodeSectionCounts {
const counts: NodeSectionCounts = {
containers: 0,
docker: 0,
vms: 0,
}
for (const node of nodes) {
if (node.health_ok !== false) {
continue
}
counts[KIND_TO_SECTION[node.kind]] += 1
}
return counts
}
export function countAuthFailuresToday(
events: ReadonlyArray<Pick<AuthAuditEvent, 'at' | 'action' | 'outcome'>>,
now: Date = new Date(),
): number {
const from = startOfLocalDay(now)
let failureCount = 0
for (const event of events) {
if (event.action !== 'login' || event.outcome !== 'failure') {
continue
}
if (!isWithinDateRange(event.at, from, null)) {
continue
}
failureCount += 1
}
return failureCount
}