Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aac9e82766 | ||
|
|
5307ba1a7b | ||
|
|
7631591f30 | ||
|
|
0e06063c1d | ||
|
|
eade2ef0c7 | ||
|
|
32af91fd30 | ||
|
|
50568127c5 | ||
|
|
463aa9a7a3 | ||
|
|
e3793f380c | ||
|
|
c90a47c3ea |
@@ -1,6 +1,14 @@
|
|||||||
# ClusterCanvas
|
# ClusterCanvas
|
||||||
|
|
||||||
A web-based dashboard for managing Proxmox VMs, LXC containers, and Docker. After configuration it discovers hosts and shows per‑instance 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
|
## Layout
|
||||||
|
|
||||||
@@ -11,7 +19,7 @@ A web-based dashboard for managing Proxmox VMs, LXC containers, and Docker. Afte
|
|||||||
|
|
||||||
## Prerequisites
|
## 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
|
- Node.js 20+ and npm
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
@@ -20,10 +28,17 @@ Service config lives under **`/etc/ClusterCanvas/`** by default:
|
|||||||
|
|
||||||
| File | Purpose |
|
| 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) |
|
| `passwords.enc` | AES-GCM encrypted user credentials (argon2id hashes, TOTP secrets) |
|
||||||
| `sessions.enc` | AES-GCM encrypted session store |
|
| `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):
|
**Config directory precedence** (highest first):
|
||||||
|
|
||||||
@@ -49,7 +64,7 @@ export CLUSTERCANVAS_CONFIG_KEY="$(openssl rand -base64 32)"
|
|||||||
make dev
|
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
|
### 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.
|
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.
|
**Do not use `REMOTEDEV_CONFIG_KEY` (or its default) in production** — generate a unique key with `openssl rand -base64 32` instead.
|
||||||
|
|
||||||
## Test
|
## Test
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -36,9 +36,10 @@ func main() {
|
|||||||
|
|
||||||
listenHost := settings.ListenHost(configDir)
|
listenHost := settings.ListenHost(configDir)
|
||||||
listenAddr := listenHost + ":" + port
|
listenAddr := listenHost + ":" + port
|
||||||
|
handler, app := api.NewRouterWithApp(configDir)
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: listenAddr,
|
Addr: listenAddr,
|
||||||
Handler: api.NewRouter(configDir),
|
Handler: handler,
|
||||||
ReadHeaderTimeout: 5 * time.Second,
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +54,13 @@ func main() {
|
|||||||
signal.Notify(stopSignals, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(stopSignals, syscall.SIGINT, syscall.SIGTERM)
|
||||||
<-stopSignals
|
<-stopSignals
|
||||||
|
|
||||||
|
if app.Executor != nil {
|
||||||
|
app.Executor.StopScheduler()
|
||||||
|
}
|
||||||
|
if app.HealthChecker != nil {
|
||||||
|
app.HealthChecker.StopScheduler()
|
||||||
|
}
|
||||||
|
|
||||||
shutdownContext, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
shutdownContext, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -10,7 +10,14 @@ import (
|
|||||||
var errUsersManageRequired = errors.New("permission users.manage required")
|
var errUsersManageRequired = errors.New("permission users.manage required")
|
||||||
var errNodesReadRequired = errors.New("permission nodes.read required")
|
var errNodesReadRequired = errors.New("permission nodes.read required")
|
||||||
var errNodesExecRequired = errors.New("permission nodes.exec 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 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.
|
// permissionsForUser returns the union of permissions from the user's groups.
|
||||||
func permissionsForUser(user settings.UserCredential, groups []settings.Group) map[string]struct{} {
|
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
|
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 {
|
func (app *App) authorizeUsersWrite(request *http.Request) error {
|
||||||
user, ok := UserFromContext(request.Context())
|
user, ok := UserFromContext(request.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -81,6 +104,22 @@ func (app *App) authorizeNodesRead(request *http.Request) error {
|
|||||||
return nil
|
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 {
|
func (app *App) authorizeNodesExec(request *http.Request) error {
|
||||||
user, ok := UserFromContext(request.Context())
|
user, ok := UserFromContext(request.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -97,6 +136,22 @@ func (app *App) authorizeNodesExec(request *http.Request) error {
|
|||||||
return nil
|
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 {
|
func (app *App) authorizeNodesDelete(request *http.Request) error {
|
||||||
user, ok := UserFromContext(request.Context())
|
user, ok := UserFromContext(request.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -112,3 +167,35 @@ func (app *App) authorizeNodesDelete(request *http.Request) error {
|
|||||||
}
|
}
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,15 +22,20 @@ type upsertGroupRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var allowedPermissions = map[string]struct{}{
|
var allowedPermissions = map[string]struct{}{
|
||||||
"nodes.read": {},
|
"nodes.read": {},
|
||||||
"nodes.exec": {},
|
"nodes.exec": {},
|
||||||
"nodes.update": {},
|
"nodes.update": {},
|
||||||
"nodes.delete": {},
|
"nodes.delete": {},
|
||||||
"jobs.read": {},
|
"jobs.read": {},
|
||||||
"jobs.run": {},
|
"jobs.run": {},
|
||||||
"users.manage": {},
|
"actions.create": {},
|
||||||
"secrets.manage": {},
|
"actions.update": {},
|
||||||
"roles.manage": {},
|
"actions.delete": {},
|
||||||
|
"actions.execute": {},
|
||||||
|
"users.manage": {},
|
||||||
|
"secrets.manage": {},
|
||||||
|
"roles.manage": {},
|
||||||
|
"logs.read": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
func groupsGetHandler(configDir string) http.HandlerFunc {
|
func groupsGetHandler(configDir string) http.HandlerFunc {
|
||||||
@@ -86,6 +91,19 @@ func groupsUpsertHandler(configDir string) http.HandlerFunc {
|
|||||||
return
|
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})
|
writeJSON(writer, http.StatusOK, groupsResponse{Groups: settingsPayload.Groups})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,6 +152,15 @@ func groupsDeleteHandler(configDir string) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
appendAuthAudit(
|
||||||
|
configDir,
|
||||||
|
"group_delete",
|
||||||
|
settings.AuthAuditOutcomeSuccess,
|
||||||
|
settings.AuthAuditCategoryGroup,
|
||||||
|
actorUsernameFromRequest(request),
|
||||||
|
name,
|
||||||
|
"",
|
||||||
|
)
|
||||||
writeJSON(writer, http.StatusOK, groupsResponse{Groups: settingsPayload.Groups})
|
writeJSON(writer, http.StatusOK, groupsResponse{Groups: settingsPayload.Groups})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,6 +140,15 @@ func (app *App) mePasswordHandler(writer http.ResponseWriter, request *http.Requ
|
|||||||
_ = app.Sessions.DestroySession(writer, request)
|
_ = 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})
|
writeJSON(writer, http.StatusOK, map[string]bool{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,6 +266,15 @@ func (app *App) meTOTPConfirmHandler(writer http.ResponseWriter, request *http.R
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
appendAuthAudit(
|
||||||
|
app.ConfigDir,
|
||||||
|
"totp_enable",
|
||||||
|
settings.AuthAuditOutcomeSuccess,
|
||||||
|
settings.AuthAuditCategorySelf,
|
||||||
|
user.Username,
|
||||||
|
user.Username,
|
||||||
|
"",
|
||||||
|
)
|
||||||
writeJSON(writer, http.StatusOK, map[string]bool{"ok": true})
|
writeJSON(writer, http.StatusOK, map[string]bool{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,5 +337,14 @@ func (app *App) meTOTPDisableHandler(writer http.ResponseWriter, request *http.R
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
appendAuthAudit(
|
||||||
|
app.ConfigDir,
|
||||||
|
"totp_disable",
|
||||||
|
settings.AuthAuditOutcomeSuccess,
|
||||||
|
settings.AuthAuditCategorySelf,
|
||||||
|
user.Username,
|
||||||
|
user.Username,
|
||||||
|
"",
|
||||||
|
)
|
||||||
writeJSON(writer, http.StatusOK, map[string]bool{"ok": true})
|
writeJSON(writer, http.StatusOK, map[string]bool{"ok": true})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -9,6 +10,8 @@ import (
|
|||||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const sessionIdleExemptHeader = "X-Session-Idle-Exempt"
|
||||||
|
|
||||||
func (app *App) withMiddleware(next http.Handler) http.Handler {
|
func (app *App) withMiddleware(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
path := request.URL.Path
|
path := request.URL.Path
|
||||||
@@ -74,9 +77,14 @@ func (app *App) withMiddleware(next http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
security := effectiveSecurity(settingsPayload.Security)
|
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 {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +145,7 @@ func (app *App) withCORS(next http.Handler) http.Handler {
|
|||||||
writer.Header().Set("Vary", "Origin")
|
writer.Header().Set("Vary", "Origin")
|
||||||
}
|
}
|
||||||
writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
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 {
|
if request.Method == http.MethodOptions {
|
||||||
writer.WriteHeader(http.StatusNoContent)
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package api
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"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) {
|
func (app *App) nodesCreateHandler(writer http.ResponseWriter, request *http.Request) {
|
||||||
if err := app.authorizeNodesExec(request); err != nil {
|
if err := app.authorizeNodesExec(request); err != nil {
|
||||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
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) {
|
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()})
|
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||||
return
|
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)
|
store, err := settings.LoadNodeAuditOrEmpty(app.ConfigDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||||
return
|
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"))
|
kindFilter := strings.TrimSpace(request.URL.Query().Get("kind"))
|
||||||
isAdmin := userIsAdministrator(user)
|
|
||||||
filtered := make([]settings.NodeAuditEvent, 0, len(store.Events))
|
filtered := make([]settings.NodeAuditEvent, 0, len(store.Events))
|
||||||
for index := len(store.Events) - 1; index >= 0; index-- {
|
for index := len(store.Events) - 1; index >= 0; index-- {
|
||||||
event := store.Events[index]
|
event := store.Events[index]
|
||||||
if kindFilter != "" && string(event.NodeKind) != kindFilter {
|
if kindFilter != "" && string(event.NodeKind) != kindFilter {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !userCanSeeNodeAuditEvent(user, settingsPayload.Groups, nodeByID, event, isAdmin) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
filtered = append(filtered, event)
|
filtered = append(filtered, event)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -609,25 +742,6 @@ func userCanAccessNode(user settings.UserCredential, groups []settings.Group, no
|
|||||||
return false
|
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(
|
func appendNodeAudit(
|
||||||
configDir string,
|
configDir string,
|
||||||
action settings.NodeAuditAction,
|
action settings.NodeAuditAction,
|
||||||
|
|||||||
@@ -357,6 +357,63 @@ func TestNodesTestSSHReportsFailureForUnreachableHost(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNodesResolveCommandsRejectsInvalidNames(t *testing.T) {
|
||||||
|
configDir := t.TempDir()
|
||||||
|
cookie := seedCompletedSetup(t, configDir)
|
||||||
|
router := NewRouter(configDir)
|
||||||
|
|
||||||
|
createBody := []byte(`{
|
||||||
|
"id":"dddddddd-bbbb-4ccc-8ddd-eeeeeeeeeeee",
|
||||||
|
"kind":"container",
|
||||||
|
"name":"resolve-test",
|
||||||
|
"host_ip":"127.0.0.1",
|
||||||
|
"group_name":"Administrators",
|
||||||
|
"generate":{"algorithm":"ed25519"}
|
||||||
|
}`)
|
||||||
|
createRequest := withSession(
|
||||||
|
httptest.NewRequest(http.MethodPost, "/api/v1/nodes", bytes.NewReader(createBody)),
|
||||||
|
cookie,
|
||||||
|
)
|
||||||
|
createRecorder := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(createRecorder, createRequest)
|
||||||
|
if createRecorder.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create status = %d body=%s", createRecorder.Code, createRecorder.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
badBody := []byte(`{"names":["/usr/bin/apt"]}`)
|
||||||
|
badRequest := withSession(
|
||||||
|
httptest.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
"/api/v1/nodes/dddddddd-bbbb-4ccc-8ddd-eeeeeeeeeeee/resolve-commands",
|
||||||
|
bytes.NewReader(badBody),
|
||||||
|
),
|
||||||
|
cookie,
|
||||||
|
)
|
||||||
|
badRecorder := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(badRecorder, badRequest)
|
||||||
|
if badRecorder.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d body=%s", badRecorder.Code, badRecorder.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(badRecorder.Body.String(), "basename") {
|
||||||
|
t.Fatalf("expected basename error, got %s", badRecorder.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyBody := []byte(`{"names":[]}`)
|
||||||
|
emptyRequest := withSession(
|
||||||
|
httptest.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
"/api/v1/nodes/dddddddd-bbbb-4ccc-8ddd-eeeeeeeeeeee/resolve-commands",
|
||||||
|
bytes.NewReader(emptyBody),
|
||||||
|
),
|
||||||
|
cookie,
|
||||||
|
)
|
||||||
|
emptyRecorder := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(emptyRecorder, emptyRequest)
|
||||||
|
if emptyRecorder.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("empty status = %d body=%s", emptyRecorder.Code, emptyRecorder.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNodesDeleteRequiresPermissionAndInlineReauth(t *testing.T) {
|
func TestNodesDeleteRequiresPermissionAndInlineReauth(t *testing.T) {
|
||||||
configDir := t.TempDir()
|
configDir := t.TempDir()
|
||||||
cookie := seedCompletedSetup(t, configDir)
|
cookie := seedCompletedSetup(t, configDir)
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -8,11 +8,23 @@ const defaultAllowedOrigin = "http://localhost:5173"
|
|||||||
|
|
||||||
// NewRouter builds the HTTP API with setup gating, auth, and network middleware.
|
// NewRouter builds the HTTP API with setup gating, auth, and network middleware.
|
||||||
func NewRouter(configDir string) http.Handler {
|
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)
|
app, err := newApp(configDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// newApp currently always succeeds; keep signature for future.
|
// newApp currently always succeeds; keep signature for future.
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
if app.Executor != nil {
|
||||||
|
app.Executor.StartScheduler()
|
||||||
|
}
|
||||||
|
if app.HealthChecker != nil {
|
||||||
|
app.HealthChecker.StartScheduler()
|
||||||
|
}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("GET /health", HealthHandler)
|
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("GET /api/v1/groups", groupsGetHandler(configDir))
|
||||||
mux.HandleFunc("POST /api/v1/groups", groupsUpsertHandler(configDir))
|
mux.HandleFunc("POST /api/v1/groups", groupsUpsertHandler(configDir))
|
||||||
mux.HandleFunc("DELETE /api/v1/groups", groupsDeleteHandler(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("GET /api/v1/security", securityGetHandler(configDir))
|
||||||
mux.HandleFunc("PUT /api/v1/security", securityPutHandler(configDir))
|
mux.HandleFunc("PUT /api/v1/security", securityPutHandler(configDir))
|
||||||
mux.HandleFunc("GET /api/v1/network", networkGetHandler(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("GET /api/v1/nodes", app.nodesListHandler)
|
||||||
mux.HandleFunc("POST /api/v1/nodes", app.nodesCreateHandler)
|
mux.HandleFunc("POST /api/v1/nodes", app.nodesCreateHandler)
|
||||||
mux.HandleFunc("GET /api/v1/nodes/{id}", app.nodesGetHandler)
|
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("DELETE /api/v1/nodes/{id}", app.nodesDeleteHandler)
|
||||||
mux.HandleFunc("POST /api/v1/nodes/{id}/test-ssh", app.nodesTestSSHHandler)
|
mux.HandleFunc("POST /api/v1/nodes/{id}/test-ssh", app.nodesTestSSHHandler)
|
||||||
|
mux.HandleFunc("POST /api/v1/nodes/{id}/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/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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
|
"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"
|
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -76,9 +78,11 @@ type meResponse struct {
|
|||||||
|
|
||||||
// App holds shared API dependencies.
|
// App holds shared API dependencies.
|
||||||
type App struct {
|
type App struct {
|
||||||
ConfigDir string
|
ConfigDir string
|
||||||
Key []byte
|
Key []byte
|
||||||
Sessions *auth.SessionManager
|
Sessions *auth.SessionManager
|
||||||
|
Executor *runner.Executor
|
||||||
|
HealthChecker *health.Checker
|
||||||
|
|
||||||
pendingTOTPMu sync.Mutex
|
pendingTOTPMu sync.Mutex
|
||||||
pendingTOTP map[string]string // username -> secret (setup only)
|
pendingTOTP map[string]string // username -> secret (setup only)
|
||||||
@@ -88,17 +92,23 @@ func newApp(configDir string) (*App, error) {
|
|||||||
key, err := settings.KeyFromEnv()
|
key, err := settings.KeyFromEnv()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Key is required for setup complete / auth; status still works without it.
|
// Key is required for setup complete / auth; status still works without it.
|
||||||
return &App{
|
app := &App{
|
||||||
ConfigDir: configDir,
|
ConfigDir: configDir,
|
||||||
pendingTOTP: map[string]string{},
|
pendingTOTP: map[string]string{},
|
||||||
}, nil
|
Executor: runner.NewExecutor(configDir, nil),
|
||||||
|
HealthChecker: health.NewChecker(configDir, nil),
|
||||||
|
}
|
||||||
|
return app, nil
|
||||||
}
|
}
|
||||||
return &App{
|
app := &App{
|
||||||
ConfigDir: configDir,
|
ConfigDir: configDir,
|
||||||
Key: key,
|
Key: key,
|
||||||
Sessions: auth.NewSessionManager(configDir, key),
|
Sessions: auth.NewSessionManager(configDir, key),
|
||||||
pendingTOTP: map[string]string{},
|
pendingTOTP: map[string]string{},
|
||||||
}, nil
|
Executor: runner.NewExecutor(configDir, key),
|
||||||
|
HealthChecker: health.NewChecker(configDir, key),
|
||||||
|
}
|
||||||
|
return app, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) requireKey() error {
|
func (app *App) requireKey() error {
|
||||||
@@ -360,13 +370,32 @@ func (app *App) loginHandler(writer http.ResponseWriter, request *http.Request)
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
attemptedUsername := strings.TrimSpace(payload.Username)
|
||||||
if matched == nil || !matched.Enabled {
|
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"})
|
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid credentials"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ok, err := auth.VerifyPassword(payload.Password, matched.PasswordHash)
|
ok, err := auth.VerifyPassword(payload.Password, matched.PasswordHash)
|
||||||
if err != nil || !ok {
|
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"})
|
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid credentials"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -381,6 +410,15 @@ func (app *App) loginHandler(writer http.ResponseWriter, request *http.Request)
|
|||||||
requiresTOTP := security.TotpEnabled && matched.TOTPConfirmed && matched.TOTPSecret != ""
|
requiresTOTP := security.TotpEnabled && matched.TOTPConfirmed && matched.TOTPSecret != ""
|
||||||
if requiresTOTP {
|
if requiresTOTP {
|
||||||
if !auth.VerifyTOTPCode(matched.TOTPSecret, payload.TOTPCode) {
|
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"})
|
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid or missing TOTP code"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -391,13 +429,34 @@ func (app *App) loginHandler(writer http.ResponseWriter, request *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
appendAuthAudit(
|
||||||
|
app.ConfigDir,
|
||||||
|
"login",
|
||||||
|
settings.AuthAuditOutcomeSuccess,
|
||||||
|
settings.AuthAuditCategoryAuth,
|
||||||
|
matched.Username,
|
||||||
|
matched.Username,
|
||||||
|
"",
|
||||||
|
)
|
||||||
writeJSON(writer, http.StatusOK, app.buildMeResponse(*matched, settingsPayload))
|
writeJSON(writer, http.StatusOK, app.buildMeResponse(*matched, settingsPayload))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) logoutHandler(writer http.ResponseWriter, request *http.Request) {
|
func (app *App) logoutHandler(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
actor := actorUsernameFromRequest(request)
|
||||||
if app.Sessions != nil {
|
if app.Sessions != nil {
|
||||||
_ = app.Sessions.DestroySession(writer, request)
|
_ = 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})
|
writeJSON(writer, http.StatusOK, map[string]bool{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,9 +512,14 @@ func allPermissionList() []string {
|
|||||||
"nodes.delete",
|
"nodes.delete",
|
||||||
"jobs.read",
|
"jobs.read",
|
||||||
"jobs.run",
|
"jobs.run",
|
||||||
|
"actions.create",
|
||||||
|
"actions.update",
|
||||||
|
"actions.delete",
|
||||||
|
"actions.execute",
|
||||||
"users.manage",
|
"users.manage",
|
||||||
"secrets.manage",
|
"secrets.manage",
|
||||||
"roles.manage",
|
"roles.manage",
|
||||||
|
"logs.read",
|
||||||
}
|
}
|
||||||
_ = list
|
_ = list
|
||||||
return ordered
|
return ordered
|
||||||
@@ -481,5 +545,67 @@ func loadSettingsOrDefault(configDir string) (settings.Settings, error) {
|
|||||||
payload.Groups = []settings.Group{}
|
payload.Groups = []settings.Group{}
|
||||||
}
|
}
|
||||||
payload.Network = settings.EffectiveNetwork(payload.Network)
|
payload.Network = settings.EffectiveNetwork(payload.Network)
|
||||||
|
changed := false
|
||||||
|
if migrateLogsReadPermission(&payload) {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if migrateActionsExecutePermission(&payload) {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
if err := settings.SaveSettings(configDir, payload); err != nil {
|
||||||
|
return settings.Settings{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
return payload, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -137,6 +137,15 @@ func (app *App) usersCreateHandler(writer http.ResponseWriter, request *http.Req
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
appendAuthAudit(
|
||||||
|
app.ConfigDir,
|
||||||
|
"user_create",
|
||||||
|
settings.AuthAuditOutcomeSuccess,
|
||||||
|
settings.AuthAuditCategoryUser,
|
||||||
|
actorUsernameFromRequest(request),
|
||||||
|
username,
|
||||||
|
"",
|
||||||
|
)
|
||||||
writeJSON(writer, http.StatusOK, usersResponse{Users: toPublicUsers(store.Users)})
|
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)
|
_ = 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)})
|
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)
|
adminCount := countAdministrators(store.Users)
|
||||||
remaining := make([]settings.UserCredential, 0, len(store.Users))
|
remaining := make([]settings.UserCredential, 0, len(store.Users))
|
||||||
found := false
|
found := false
|
||||||
|
deletedUsername := ""
|
||||||
for _, user := range store.Users {
|
for _, user := range store.Users {
|
||||||
if user.ID != userID {
|
if user.ID != userID {
|
||||||
remaining = append(remaining, user)
|
remaining = append(remaining, user)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
found = true
|
found = true
|
||||||
|
deletedUsername = user.Username
|
||||||
if userIsAdministrator(user) && adminCount <= 1 {
|
if userIsAdministrator(user) && adminCount <= 1 {
|
||||||
writeJSON(writer, http.StatusConflict, apiErrorResponse{
|
writeJSON(writer, http.StatusConflict, apiErrorResponse{
|
||||||
Error: "cannot delete the last administrator",
|
Error: "cannot delete the last administrator",
|
||||||
@@ -322,6 +342,15 @@ func (app *App) usersDeleteHandler(writer http.ResponseWriter, request *http.Req
|
|||||||
_ = app.Sessions.InvalidateUserSessions(userID)
|
_ = 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)})
|
writeJSON(writer, http.StatusOK, usersResponse{Users: toPublicUsers(store.Users)})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,10 @@ const (
|
|||||||
sessionRotateAfter = time.Hour
|
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.
|
// SessionManager persists sessions in sessions.enc and sets hardened cookies.
|
||||||
type SessionManager struct {
|
type SessionManager struct {
|
||||||
@@ -84,11 +87,15 @@ func (manager *SessionManager) CreateSession(
|
|||||||
return record, nil
|
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(
|
func (manager *SessionManager) LookupValidSession(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
security settings.SecuritySettings,
|
security settings.SecuritySettings,
|
||||||
|
touchActivity bool,
|
||||||
) (settings.SessionRecord, error) {
|
) (settings.SessionRecord, error) {
|
||||||
cookie, err := request.Cookie(SessionCookieName)
|
cookie, err := request.Cookie(SessionCookieName)
|
||||||
if err != nil || strings.TrimSpace(cookie.Value) == "" {
|
if err != nil || strings.TrimSpace(cookie.Value) == "" {
|
||||||
@@ -108,6 +115,7 @@ func (manager *SessionManager) LookupValidSession(
|
|||||||
lifetime := time.Duration(security.SessionLifetimeHours) * time.Hour
|
lifetime := time.Duration(security.SessionLifetimeHours) * time.Hour
|
||||||
|
|
||||||
var found *settings.SessionRecord
|
var found *settings.SessionRecord
|
||||||
|
idleMatched := false
|
||||||
remaining := make([]settings.SessionRecord, 0, len(store.Sessions))
|
remaining := make([]settings.SessionRecord, 0, len(store.Sessions))
|
||||||
for index := range store.Sessions {
|
for index := range store.Sessions {
|
||||||
session := store.Sessions[index]
|
session := store.Sessions[index]
|
||||||
@@ -116,6 +124,7 @@ func (manager *SessionManager) LookupValidSession(
|
|||||||
}
|
}
|
||||||
if session.ID == cookie.Value {
|
if session.ID == cookie.Value {
|
||||||
if now.Sub(session.LastSeenAt) > idleLimit {
|
if now.Sub(session.LastSeenAt) > idleLimit {
|
||||||
|
idleMatched = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
copySession := session
|
copySession := session
|
||||||
@@ -129,9 +138,16 @@ func (manager *SessionManager) LookupValidSession(
|
|||||||
store.Sessions = remaining
|
store.Sessions = remaining
|
||||||
_ = settings.SaveSessions(manager.configDir, store, manager.key)
|
_ = settings.SaveSessions(manager.configDir, store, manager.key)
|
||||||
clearSessionCookie(writer)
|
clearSessionCookie(writer)
|
||||||
|
if idleMatched {
|
||||||
|
return settings.SessionRecord{}, ErrSessionIdle
|
||||||
|
}
|
||||||
return settings.SessionRecord{}, ErrSessionNotFound
|
return settings.SessionRecord{}, ErrSessionNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !touchActivity {
|
||||||
|
return *found, nil
|
||||||
|
}
|
||||||
|
|
||||||
shouldRotate := now.Sub(found.CreatedAt) >= sessionRotateAfter ||
|
shouldRotate := now.Sub(found.CreatedAt) >= sessionRotateAfter ||
|
||||||
now.Sub(found.CreatedAt) >= lifetime/2
|
now.Sub(found.CreatedAt) >= lifetime/2
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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, "'", `'\''`) + "'"
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -19,6 +19,10 @@ const (
|
|||||||
NodesFileName = "nodes.json"
|
NodesFileName = "nodes.json"
|
||||||
NodeKeysFileName = "node-keys.enc"
|
NodeKeysFileName = "node-keys.enc"
|
||||||
NodeAuditFileName = "node-audit.json"
|
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:
|
// ResolveDir returns the config directory using precedence:
|
||||||
|
|||||||
@@ -105,6 +105,14 @@ type Node struct {
|
|||||||
PublicKey string `json:"public_key"`
|
PublicKey string `json:"public_key"`
|
||||||
KeyAlgo string `json:"key_algo"`
|
KeyAlgo string `json:"key_algo"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
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.
|
// NodeStore is the plain JSON payload in nodes.json.
|
||||||
@@ -112,6 +120,159 @@ type NodeStore struct {
|
|||||||
Nodes []Node `json:"nodes"`
|
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.
|
// NodeKeyEntry holds private key material for one node, keyed by node UUID.
|
||||||
type NodeKeyEntry struct {
|
type NodeKeyEntry struct {
|
||||||
NodeID string `json:"node_id"`
|
NodeID string `json:"node_id"`
|
||||||
@@ -131,10 +292,11 @@ type NodeKeyStore struct {
|
|||||||
type NodeAuditAction string
|
type NodeAuditAction string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
NodeAuditActionCreate NodeAuditAction = "create"
|
NodeAuditActionCreate NodeAuditAction = "create"
|
||||||
NodeAuditActionDelete NodeAuditAction = "delete"
|
NodeAuditActionDelete NodeAuditAction = "delete"
|
||||||
NodeAuditActionTestSSH NodeAuditAction = "test_ssh"
|
NodeAuditActionTestSSH NodeAuditAction = "test_ssh"
|
||||||
NodeAuditActionUpdate NodeAuditAction = "update"
|
NodeAuditActionHealthCheck NodeAuditAction = "health_check"
|
||||||
|
NodeAuditActionUpdate NodeAuditAction = "update"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NodeAuditEvent is one append-only record of a node mutation.
|
// NodeAuditEvent is one append-only record of a node mutation.
|
||||||
@@ -154,6 +316,41 @@ type NodeAuditStore struct {
|
|||||||
Events []NodeAuditEvent `json:"events"`
|
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.
|
// UserCredential is one account stored in passwords.enc.
|
||||||
type UserCredential struct {
|
type UserCredential struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
|||||||
+52
-25
@@ -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)
|
## Develop
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
|
||||||
|
|
||||||
## 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).
|
```bash
|
||||||
|
npm install
|
||||||
## Expanding the Oxlint configuration
|
npm run dev
|
||||||
|
|
||||||
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 }]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
File diff suppressed because it is too large
Load Diff
+487
-6
@@ -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 userEvent from '@testing-library/user-event'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
import { THEME_STORAGE_KEY } from './theme'
|
import { THEME_STORAGE_KEY } from './theme'
|
||||||
|
|
||||||
function stubFetchOk() {
|
function stubFetchOk() {
|
||||||
vi.stubGlobal(
|
const fetchMock = vi.fn().mockImplementation((input: RequestInfo | URL) => {
|
||||||
'fetch',
|
|
||||||
vi.fn().mockImplementation((input: RequestInfo | URL) => {
|
|
||||||
const url = typeof input === 'string' ? input : input.toString()
|
const url = typeof input === 'string' ? input : input.toString()
|
||||||
|
|
||||||
if (url.includes('/api/v1/setup/status')) {
|
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')) {
|
if (url.includes('/api/v1/auth/me')) {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -33,9 +38,14 @@ function stubFetchOk() {
|
|||||||
'nodes.delete',
|
'nodes.delete',
|
||||||
'jobs.read',
|
'jobs.read',
|
||||||
'jobs.run',
|
'jobs.run',
|
||||||
|
'actions.create',
|
||||||
|
'actions.update',
|
||||||
|
'actions.delete',
|
||||||
|
'actions.execute',
|
||||||
'users.manage',
|
'users.manage',
|
||||||
'secrets.manage',
|
'secrets.manage',
|
||||||
'roles.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')) {
|
if (url.includes('/api/v1/groups')) {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -74,9 +91,14 @@ function stubFetchOk() {
|
|||||||
'nodes.delete',
|
'nodes.delete',
|
||||||
'jobs.read',
|
'jobs.read',
|
||||||
'jobs.run',
|
'jobs.run',
|
||||||
|
'actions.create',
|
||||||
|
'actions.update',
|
||||||
|
'actions.delete',
|
||||||
|
'actions.execute',
|
||||||
'users.manage',
|
'users.manage',
|
||||||
'secrets.manage',
|
'secrets.manage',
|
||||||
'roles.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')) {
|
if (url.includes('/api/v1/nodes')) {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -156,8 +210,10 @@ function stubFetchOk() {
|
|||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({}),
|
json: async () => ({}),
|
||||||
})
|
})
|
||||||
}),
|
})
|
||||||
)
|
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
return fetchMock
|
||||||
}
|
}
|
||||||
|
|
||||||
function stubMatchMedia(prefersDark: boolean) {
|
function stubMatchMedia(prefersDark: boolean) {
|
||||||
@@ -188,6 +244,7 @@ describe('App', () => {
|
|||||||
localStorage.clear()
|
localStorage.clear()
|
||||||
delete document.documentElement.dataset.theme
|
delete document.documentElement.dataset.theme
|
||||||
window.history.replaceState(null, '', '/')
|
window.history.replaceState(null, '', '/')
|
||||||
|
vi.useRealTimers()
|
||||||
vi.unstubAllGlobals()
|
vi.unstubAllGlobals()
|
||||||
vi.restoreAllMocks()
|
vi.restoreAllMocks()
|
||||||
})
|
})
|
||||||
@@ -210,6 +267,7 @@ describe('App', () => {
|
|||||||
expect(
|
expect(
|
||||||
screen.getByRole('button', { name: 'Containers' }),
|
screen.getByRole('button', { name: 'Containers' }),
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Activity' })).toBeInTheDocument()
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole('button', { name: 'Configuration' }),
|
screen.getByRole('button', { name: 'Configuration' }),
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
@@ -221,6 +279,370 @@ describe('App', () => {
|
|||||||
expect(screen.getByText('Coming soon')).toBeInTheDocument()
|
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 () => {
|
it('opens Profile from the sidebar and updates the URL', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
stubFetchOk()
|
stubFetchOk()
|
||||||
@@ -243,6 +665,32 @@ describe('App', () => {
|
|||||||
).toBeInTheDocument()
|
).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 () => {
|
it('restores Profile from the URL on load', async () => {
|
||||||
stubFetchOk()
|
stubFetchOk()
|
||||||
window.history.replaceState(null, '', '/profile')
|
window.history.replaceState(null, '', '/profile')
|
||||||
@@ -355,6 +803,7 @@ describe('App', () => {
|
|||||||
'Users',
|
'Users',
|
||||||
'Roles',
|
'Roles',
|
||||||
'Groups',
|
'Groups',
|
||||||
|
'Actions',
|
||||||
'Security',
|
'Security',
|
||||||
'Integrations',
|
'Integrations',
|
||||||
'Network',
|
'Network',
|
||||||
@@ -378,9 +827,14 @@ describe('App', () => {
|
|||||||
'nodes.delete',
|
'nodes.delete',
|
||||||
'jobs.read',
|
'jobs.read',
|
||||||
'jobs.run',
|
'jobs.run',
|
||||||
|
'actions.create',
|
||||||
|
'actions.update',
|
||||||
|
'actions.delete',
|
||||||
|
'actions.execute',
|
||||||
'users.manage',
|
'users.manage',
|
||||||
'secrets.manage',
|
'secrets.manage',
|
||||||
'roles.manage',
|
'roles.manage',
|
||||||
|
'logs.read',
|
||||||
]) {
|
]) {
|
||||||
expect(screen.getByText(permission)).toBeInTheDocument()
|
expect(screen.getByText(permission)).toBeInTheDocument()
|
||||||
}
|
}
|
||||||
@@ -395,6 +849,33 @@ describe('App', () => {
|
|||||||
expect(screen.getByLabelText('roles.manage')).toBeInTheDocument()
|
expect(screen.getByLabelText('roles.manage')).toBeInTheDocument()
|
||||||
expect(screen.queryByLabelText('Theme')).not.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' }))
|
await user.click(screen.getByRole('tab', { name: 'Security' }))
|
||||||
expect(screen.getByRole('tab', { name: 'Security' })).toHaveAttribute(
|
expect(screen.getByRole('tab', { name: 'Security' })).toHaveAttribute(
|
||||||
'aria-selected',
|
'aria-selected',
|
||||||
|
|||||||
+1517
-158
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
+58
-3
@@ -1,15 +1,64 @@
|
|||||||
import { useState, type FormEvent } from 'react'
|
import { useState, type FormEvent } from 'react'
|
||||||
import { login } from './api/client'
|
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 = {
|
type LoginPageProps = {
|
||||||
onLoggedIn: (username: string) => void
|
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) {
|
export function LoginPage({ onLoggedIn }: LoginPageProps) {
|
||||||
const [username, setUsername] = useState('Admin')
|
const [username, setUsername] = useState('Admin')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [totpCode, setTotpCode] = useState('')
|
const [totpCode, setTotpCode] = useState('')
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||||
|
const [signedOutMessage] = useState<string | null>(() =>
|
||||||
|
readSignedOutMessage(),
|
||||||
|
)
|
||||||
|
const [showSetupHint] = useState(() =>
|
||||||
|
consumeLoginQueryFlag('from', 'setup'),
|
||||||
|
)
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
async function handleSubmit(event: FormEvent) {
|
async function handleSubmit(event: FormEvent) {
|
||||||
@@ -31,9 +80,15 @@ export function LoginPage({ onLoggedIn }: LoginPageProps) {
|
|||||||
<form className="wizard-card" onSubmit={(event) => void handleSubmit(event)}>
|
<form className="wizard-card" onSubmit={(event) => void handleSubmit(event)}>
|
||||||
<p className="wizard-brand">Cluster Canvas</p>
|
<p className="wizard-brand">Cluster Canvas</p>
|
||||||
<h1 className="wizard-title">Sign in</h1>
|
<h1 className="wizard-title">Sign in</h1>
|
||||||
<p className="config-hint">
|
{showSetupHint ? (
|
||||||
Use the administrator account created during setup.
|
<p className="config-hint">{SETUP_HINT_MESSAGE}</p>
|
||||||
</p>
|
) : null}
|
||||||
|
|
||||||
|
{signedOutMessage ? (
|
||||||
|
<p className="wizard-notice" role="status">
|
||||||
|
{signedOutMessage}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{errorMessage ? (
|
{errorMessage ? (
|
||||||
<p className="wizard-error" role="alert">
|
<p className="wizard-error" role="alert">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,188 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import type { Action, NodeActionItem } from './api/client'
|
||||||
|
import {
|
||||||
|
applyResolvedPathToCommand,
|
||||||
|
collectRelativeCommandNames,
|
||||||
|
collectRunsAsPreviews,
|
||||||
|
collectSudoersLines,
|
||||||
|
commandArgv0,
|
||||||
|
runsAsRemoteCommand,
|
||||||
|
stripLeadingSudo,
|
||||||
|
} from './actionSudoers'
|
||||||
|
|
||||||
|
describe('stripLeadingSudo', () => {
|
||||||
|
it('strips a leading sudo', () => {
|
||||||
|
expect(stripLeadingSudo('sudo apt update')).toBe('apt update')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves commands without sudo', () => {
|
||||||
|
expect(stripLeadingSudo('/usr/bin/apt update')).toBe('/usr/bin/apt update')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('commandArgv0 and applyResolvedPathToCommand', () => {
|
||||||
|
it('extracts argv0', () => {
|
||||||
|
expect(commandArgv0('apt update')).toBe('apt')
|
||||||
|
expect(commandArgv0('/usr/bin/apt update')).toBe('/usr/bin/apt')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rewrites relative argv0 from a paths map', () => {
|
||||||
|
expect(
|
||||||
|
applyResolvedPathToCommand('apt update', { apt: '/usr/bin/apt' }),
|
||||||
|
).toBe('/usr/bin/apt update')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves absolute argv0 unchanged', () => {
|
||||||
|
expect(
|
||||||
|
applyResolvedPathToCommand('/usr/bin/apt update', {
|
||||||
|
apt: '/usr/bin/apt',
|
||||||
|
}),
|
||||||
|
).toBe('/usr/bin/apt update')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('collectRelativeCommandNames', () => {
|
||||||
|
it('collects unique relative shell argv0 names', () => {
|
||||||
|
const libraryById = new Map<string, Action>()
|
||||||
|
const items: NodeActionItem[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
source: 'local',
|
||||||
|
name: 'Update',
|
||||||
|
kind: 'shell',
|
||||||
|
body: 'apt update',
|
||||||
|
requires_sudo: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
source: 'local',
|
||||||
|
name: 'Upgrade',
|
||||||
|
kind: 'shell',
|
||||||
|
body: 'apt upgrade -y',
|
||||||
|
requires_sudo: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
source: 'local',
|
||||||
|
name: 'Abs',
|
||||||
|
kind: 'shell',
|
||||||
|
body: '/usr/bin/systemctl restart x',
|
||||||
|
requires_sudo: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
source: 'local',
|
||||||
|
name: 'Script',
|
||||||
|
kind: 'script',
|
||||||
|
body: 'apt update',
|
||||||
|
requires_sudo: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
expect(collectRelativeCommandNames(items, libraryById)).toEqual(['apt'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('runsAsRemoteCommand', () => {
|
||||||
|
it('wraps shell commands with sudo -n', () => {
|
||||||
|
expect(runsAsRemoteCommand('shell', 'apt update', true)).toBe(
|
||||||
|
'sudo -n apt update',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies resolved paths to shell previews', () => {
|
||||||
|
expect(
|
||||||
|
runsAsRemoteCommand('shell', 'apt update', true, {
|
||||||
|
apt: '/usr/bin/apt',
|
||||||
|
}),
|
||||||
|
).toBe('sudo -n /usr/bin/apt update')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses bash -s for scripts with sudo', () => {
|
||||||
|
expect(
|
||||||
|
runsAsRemoteCommand('script', 'apt update\napt upgrade -y', true),
|
||||||
|
).toBe('sudo -n bash -s')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('collectSudoersLines', () => {
|
||||||
|
const libraryById = new Map<string, Action>()
|
||||||
|
|
||||||
|
it('suggests the shell body for shell actions', () => {
|
||||||
|
const items: NodeActionItem[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
source: 'local',
|
||||||
|
name: 'Update',
|
||||||
|
kind: 'shell',
|
||||||
|
body: 'apt update',
|
||||||
|
requires_sudo: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
expect(collectSudoersLines(items, libraryById, 'clustercanvas')).toEqual([
|
||||||
|
'clustercanvas ALL=(root) NOPASSWD: apt update',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies resolved paths to sudoers suggestions', () => {
|
||||||
|
const items: NodeActionItem[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
source: 'local',
|
||||||
|
name: 'Update',
|
||||||
|
kind: 'shell',
|
||||||
|
body: 'apt update',
|
||||||
|
requires_sudo: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
expect(
|
||||||
|
collectSudoersLines(items, libraryById, 'clustercanvas', {
|
||||||
|
apt: '/usr/bin/apt',
|
||||||
|
}),
|
||||||
|
).toEqual(['clustercanvas ALL=(root) NOPASSWD: /usr/bin/apt update'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('suggests bash -s for script actions, not script lines', () => {
|
||||||
|
const items: NodeActionItem[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
source: 'local',
|
||||||
|
name: 'Update',
|
||||||
|
kind: 'script',
|
||||||
|
body: 'apt update\napt upgrade -y',
|
||||||
|
requires_sudo: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
expect(collectSudoersLines(items, libraryById, 'clustercanvas')).toEqual([
|
||||||
|
'clustercanvas ALL=(root) NOPASSWD: /usr/bin/bash -s',
|
||||||
|
'clustercanvas ALL=(root) NOPASSWD: /bin/bash -s',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('collectRunsAsPreviews', () => {
|
||||||
|
it('lists unique remote commands for sudo items', () => {
|
||||||
|
const libraryById = new Map<string, Action>()
|
||||||
|
const items: NodeActionItem[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
source: 'local',
|
||||||
|
name: 'Update',
|
||||||
|
kind: 'shell',
|
||||||
|
body: 'apt update',
|
||||||
|
requires_sudo: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
source: 'local',
|
||||||
|
name: 'Scripted',
|
||||||
|
kind: 'script',
|
||||||
|
body: 'apt update',
|
||||||
|
requires_sudo: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
expect(collectRunsAsPreviews(items, libraryById)).toEqual([
|
||||||
|
'sudo -n apt update',
|
||||||
|
'sudo -n bash -s',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import type { Action, NodeActionItem } from './api/client'
|
||||||
|
|
||||||
|
export function itemRequiresSudo(
|
||||||
|
item: NodeActionItem,
|
||||||
|
libraryById: Map<string, Action>,
|
||||||
|
): boolean {
|
||||||
|
if (item.source === 'local') {
|
||||||
|
return Boolean(item.requires_sudo)
|
||||||
|
}
|
||||||
|
const libraryAction = libraryById.get(item.library_action_id || '')
|
||||||
|
return Boolean(libraryAction?.requires_sudo)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function itemResolvedBody(
|
||||||
|
item: NodeActionItem,
|
||||||
|
libraryById: Map<string, Action>,
|
||||||
|
): { kind: 'shell' | 'script'; body: string } | null {
|
||||||
|
if (item.source === 'local') {
|
||||||
|
if (!item.body?.trim()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: item.kind === 'script' ? 'script' : 'shell',
|
||||||
|
body: item.body,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const libraryAction = libraryById.get(item.library_action_id || '')
|
||||||
|
if (!libraryAction) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return { kind: libraryAction.kind, body: libraryAction.body }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripLeadingSudo(command: string): string {
|
||||||
|
const trimmed = command.trim()
|
||||||
|
if (/^sudo(\s|$)/i.test(trimmed)) {
|
||||||
|
return trimmed.replace(/^sudo\s+/i, '').trim()
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First whitespace-separated token of a shell command body. */
|
||||||
|
export function commandArgv0(command: string): string {
|
||||||
|
const trimmed = stripLeadingSudo(command)
|
||||||
|
if (!trimmed) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const match = trimmed.match(/^[^\s]+/)
|
||||||
|
return match ? match[0] : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replace argv0 when paths has a mapping for that relative name. */
|
||||||
|
export function applyResolvedPathToCommand(
|
||||||
|
command: string,
|
||||||
|
paths: Readonly<Record<string, string>>,
|
||||||
|
): string {
|
||||||
|
const trimmed = command.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
const argv0 = commandArgv0(trimmed)
|
||||||
|
if (!argv0 || argv0.startsWith('/')) {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
const absolute = paths[argv0]
|
||||||
|
if (!absolute) {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
return `${absolute}${trimmed.slice(argv0.length)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Relative argv0 names from shell sudo items (for resolve-commands). */
|
||||||
|
export function collectRelativeCommandNames(
|
||||||
|
items: ReadonlyArray<NodeActionItem>,
|
||||||
|
libraryById: Map<string, Action>,
|
||||||
|
): string[] {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const names: string[] = []
|
||||||
|
for (const item of items) {
|
||||||
|
if (!itemRequiresSudo(item, libraryById)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const resolved = itemResolvedBody(item, libraryById)
|
||||||
|
if (!resolved || resolved.kind !== 'shell') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const argv0 = commandArgv0(resolved.body)
|
||||||
|
if (!argv0 || argv0.startsWith('/') || seen.has(argv0)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen.add(argv0)
|
||||||
|
names.push(argv0)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remote command ClusterCanvas runs (env exports omitted). */
|
||||||
|
export function runsAsRemoteCommand(
|
||||||
|
kind: 'shell' | 'script',
|
||||||
|
body: string,
|
||||||
|
requiresSudo: boolean,
|
||||||
|
paths?: Readonly<Record<string, string>>,
|
||||||
|
): string {
|
||||||
|
if (kind === 'script') {
|
||||||
|
return requiresSudo ? 'sudo -n bash -s' : 'bash -s'
|
||||||
|
}
|
||||||
|
let command = stripLeadingSudo(body)
|
||||||
|
if (paths) {
|
||||||
|
command = applyResolvedPathToCommand(command, paths)
|
||||||
|
}
|
||||||
|
if (!command) {
|
||||||
|
return requiresSudo ? 'sudo -n' : ''
|
||||||
|
}
|
||||||
|
return requiresSudo ? `sudo -n ${command}` : command
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCRIPT_SUDO_COMMANDS = ['/usr/bin/bash -s', '/bin/bash -s'] as const
|
||||||
|
|
||||||
|
function exactSudoersCommands(
|
||||||
|
kind: 'shell' | 'script',
|
||||||
|
body: string,
|
||||||
|
paths?: Readonly<Record<string, string>>,
|
||||||
|
): string[] {
|
||||||
|
if (kind === 'script') {
|
||||||
|
return [...SCRIPT_SUDO_COMMANDS]
|
||||||
|
}
|
||||||
|
let command = stripLeadingSudo(body)
|
||||||
|
if (paths) {
|
||||||
|
command = applyResolvedPathToCommand(command, paths)
|
||||||
|
}
|
||||||
|
return command ? [command] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectSudoersLines(
|
||||||
|
items: ReadonlyArray<NodeActionItem>,
|
||||||
|
libraryById: Map<string, Action>,
|
||||||
|
username: string,
|
||||||
|
paths?: Readonly<Record<string, string>>,
|
||||||
|
): string[] {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const lines: string[] = []
|
||||||
|
for (const item of items) {
|
||||||
|
if (!itemRequiresSudo(item, libraryById)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const resolved = itemResolvedBody(item, libraryById)
|
||||||
|
if (!resolved) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for (const command of exactSudoersCommands(
|
||||||
|
resolved.kind,
|
||||||
|
resolved.body,
|
||||||
|
paths,
|
||||||
|
)) {
|
||||||
|
const line = `${username} ALL=(root) NOPASSWD: ${command}`
|
||||||
|
if (seen.has(line)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen.add(line)
|
||||||
|
lines.push(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectRunsAsPreviews(
|
||||||
|
items: ReadonlyArray<NodeActionItem>,
|
||||||
|
libraryById: Map<string, Action>,
|
||||||
|
paths?: Readonly<Record<string, string>>,
|
||||||
|
): string[] {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const previews: string[] = []
|
||||||
|
for (const item of items) {
|
||||||
|
if (!itemRequiresSudo(item, libraryById)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const resolved = itemResolvedBody(item, libraryById)
|
||||||
|
if (!resolved) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const preview = runsAsRemoteCommand(
|
||||||
|
resolved.kind,
|
||||||
|
resolved.body,
|
||||||
|
true,
|
||||||
|
paths,
|
||||||
|
)
|
||||||
|
if (!preview || seen.has(preview)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen.add(preview)
|
||||||
|
previews.push(preview)
|
||||||
|
}
|
||||||
|
return previews
|
||||||
|
}
|
||||||
@@ -1,16 +1,22 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import {
|
import {
|
||||||
|
createAction,
|
||||||
|
deleteAction,
|
||||||
deleteGroup,
|
deleteGroup,
|
||||||
deleteNode,
|
deleteNode,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
|
fetchActions,
|
||||||
fetchGroups,
|
fetchGroups,
|
||||||
fetchHealth,
|
fetchHealth,
|
||||||
fetchNetwork,
|
fetchNetwork,
|
||||||
|
fetchAuthLogs,
|
||||||
fetchNodeLogs,
|
fetchNodeLogs,
|
||||||
fetchSecurity,
|
fetchSecurity,
|
||||||
fetchStatus,
|
fetchStatus,
|
||||||
fetchUsers,
|
fetchUsers,
|
||||||
getApiBaseUrl,
|
getApiBaseUrl,
|
||||||
|
login,
|
||||||
|
patchAction,
|
||||||
saveNetwork,
|
saveNetwork,
|
||||||
saveSecurity,
|
saveSecurity,
|
||||||
upsertGroup,
|
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', () => {
|
describe('fetchSecurity', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks()
|
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 () => {
|
it('creates a node with generate options', async () => {
|
||||||
const { createNode } = await import('./client')
|
const { createNode } = await import('./client')
|
||||||
const fetchMock = vi.fn().mockResolvedValue({
|
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
@@ -154,20 +154,61 @@ async function readErrorMessage(response: Response): Promise<string> {
|
|||||||
return `request failed: ${response.status}`
|
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(
|
async function apiFetch(
|
||||||
path: string,
|
path: string,
|
||||||
init: RequestInit = {},
|
init: RequestInit = {},
|
||||||
fetchImpl: typeof fetch = fetch,
|
fetchImpl: typeof fetch = fetch,
|
||||||
|
options: ApiFetchOptions = {},
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const headers = new Headers(init.headers)
|
const headers = new Headers(init.headers)
|
||||||
if (init.body && !headers.has('Content-Type')) {
|
if (init.body && !headers.has('Content-Type')) {
|
||||||
headers.set('Content-Type', 'application/json')
|
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,
|
...init,
|
||||||
headers,
|
headers,
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
|
await redirectIfSessionIdle(response, path)
|
||||||
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchHealth(
|
export async function fetchHealth(
|
||||||
@@ -617,6 +658,12 @@ export type Node = {
|
|||||||
public_key: string
|
public_key: string
|
||||||
key_algo: string
|
key_algo: string
|
||||||
created_at: 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 = {
|
export type NodesResponse = {
|
||||||
@@ -648,9 +695,10 @@ export type CreateNodeRequest = {
|
|||||||
export async function fetchNodes(
|
export async function fetchNodes(
|
||||||
kind?: NodeKind,
|
kind?: NodeKind,
|
||||||
fetchImpl: typeof fetch = fetch,
|
fetchImpl: typeof fetch = fetch,
|
||||||
|
options: ApiFetchOptions = {},
|
||||||
): Promise<NodesResponse> {
|
): Promise<NodesResponse> {
|
||||||
const query = kind ? `?kind=${encodeURIComponent(kind)}` : ''
|
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) {
|
if (!response.ok) {
|
||||||
throw new Error(await readErrorMessage(response))
|
throw new Error(await readErrorMessage(response))
|
||||||
}
|
}
|
||||||
@@ -732,7 +780,70 @@ export async function testNodeSSH(
|
|||||||
return (await response.json()) as NodeSSHTestResponse
|
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 = {
|
export type NodeAuditEvent = {
|
||||||
id: string
|
id: string
|
||||||
@@ -760,3 +871,491 @@ export async function fetchNodeLogs(
|
|||||||
}
|
}
|
||||||
return (await response.json()) as NodeLogsResponse
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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]}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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]}`
|
||||||
|
}
|
||||||
@@ -12,6 +12,10 @@
|
|||||||
--ctp-subtext0: #6c6f85;
|
--ctp-subtext0: #6c6f85;
|
||||||
--ctp-subtext1: #5c5f77;
|
--ctp-subtext1: #5c5f77;
|
||||||
--ctp-mauve: #8839ef;
|
--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-muted: color-mix(in srgb, var(--ctp-mauve) 18%, var(--ctp-base));
|
||||||
--ctp-mauve-hover: color-mix(in srgb, var(--ctp-mauve) 12%, var(--ctp-base));
|
--ctp-mauve-hover: color-mix(in srgb, var(--ctp-mauve) 12%, var(--ctp-base));
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
@@ -30,6 +34,10 @@
|
|||||||
--ctp-subtext0: #a6adc8;
|
--ctp-subtext0: #a6adc8;
|
||||||
--ctp-subtext1: #bac2de;
|
--ctp-subtext1: #bac2de;
|
||||||
--ctp-mauve: #cba6f7;
|
--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-muted: color-mix(in srgb, var(--ctp-mauve) 22%, var(--ctp-base));
|
||||||
--ctp-mauve-hover: color-mix(in srgb, var(--ctp-mauve) 14%, var(--ctp-base));
|
--ctp-mauve-hover: color-mix(in srgb, var(--ctp-mauve) 14%, var(--ctp-base));
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
|
|||||||
@@ -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 26–40 of 40')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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}`
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -17,8 +17,21 @@ describe('pathFor', () => {
|
|||||||
expect(pathFor('configuration')).toBe('/configuration')
|
expect(pathFor('configuration')).toBe('/configuration')
|
||||||
expect(pathFor('configuration', 'overview')).toBe('/configuration')
|
expect(pathFor('configuration', 'overview')).toBe('/configuration')
|
||||||
expect(pathFor('configuration', 'groups')).toBe('/configuration/groups')
|
expect(pathFor('configuration', 'groups')).toBe('/configuration/groups')
|
||||||
|
expect(pathFor('configuration', 'actions')).toBe('/configuration/actions')
|
||||||
expect(pathFor('configuration', 'roles')).toBe('/configuration/roles')
|
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', () => {
|
describe('parseLocation', () => {
|
||||||
@@ -39,14 +52,25 @@ describe('parseLocation', () => {
|
|||||||
'/configuration/users',
|
'/configuration/users',
|
||||||
'/configuration/roles',
|
'/configuration/roles',
|
||||||
'/configuration/groups',
|
'/configuration/groups',
|
||||||
|
'/configuration/actions',
|
||||||
'/configuration/security',
|
'/configuration/security',
|
||||||
'/configuration/integrations',
|
'/configuration/integrations',
|
||||||
'/configuration/network',
|
'/configuration/network',
|
||||||
'/configuration/advanced',
|
'/configuration/advanced',
|
||||||
|
'/containers/node-1',
|
||||||
|
'/containers/node-1/actions',
|
||||||
|
'/containers/node-1/healthcheck',
|
||||||
|
'/vms/node-2/logs',
|
||||||
]) {
|
]) {
|
||||||
const location = parseLocation(path)
|
const location = parseLocation(path)
|
||||||
expect(
|
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)
|
).toBe(path === '/docker/overview' ? '/docker' : path)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -56,21 +80,29 @@ describe('parseLocation', () => {
|
|||||||
section: 'overview',
|
section: 'overview',
|
||||||
configTab: 'overview',
|
configTab: 'overview',
|
||||||
resourceTab: 'overview',
|
resourceTab: 'overview',
|
||||||
|
nodeId: null,
|
||||||
|
nodeDetailTab: 'overview',
|
||||||
})
|
})
|
||||||
expect(parseLocation('/configuration/unknown')).toEqual({
|
expect(parseLocation('/configuration/unknown')).toEqual({
|
||||||
section: 'overview',
|
section: 'overview',
|
||||||
configTab: 'overview',
|
configTab: 'overview',
|
||||||
resourceTab: 'overview',
|
resourceTab: 'overview',
|
||||||
|
nodeId: null,
|
||||||
|
nodeDetailTab: 'overview',
|
||||||
})
|
})
|
||||||
expect(parseLocation('/vms/extra')).toEqual({
|
expect(parseLocation('/vms/extra')).toEqual({
|
||||||
section: 'overview',
|
section: 'vms',
|
||||||
configTab: 'overview',
|
configTab: 'overview',
|
||||||
resourceTab: 'overview',
|
resourceTab: 'overview',
|
||||||
|
nodeId: 'extra',
|
||||||
|
nodeDetailTab: 'overview',
|
||||||
})
|
})
|
||||||
expect(parseLocation('/docker/extra')).toEqual({
|
expect(parseLocation('/docker/extra/nope')).toEqual({
|
||||||
section: 'overview',
|
section: 'overview',
|
||||||
configTab: 'overview',
|
configTab: 'overview',
|
||||||
resourceTab: 'overview',
|
resourceTab: 'overview',
|
||||||
|
nodeId: null,
|
||||||
|
nodeDetailTab: 'overview',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -79,11 +111,8 @@ describe('parseLocation', () => {
|
|||||||
section: 'configuration',
|
section: 'configuration',
|
||||||
configTab: 'groups',
|
configTab: 'groups',
|
||||||
resourceTab: 'overview',
|
resourceTab: 'overview',
|
||||||
})
|
nodeId: null,
|
||||||
expect(parseLocation('/containers/add/')).toEqual({
|
nodeDetailTab: 'overview',
|
||||||
section: 'containers',
|
|
||||||
configTab: 'overview',
|
|
||||||
resourceTab: 'add',
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+57
-10
@@ -14,6 +14,7 @@ export type ConfigTabId =
|
|||||||
| 'users'
|
| 'users'
|
||||||
| 'roles'
|
| 'roles'
|
||||||
| 'groups'
|
| 'groups'
|
||||||
|
| 'actions'
|
||||||
| 'security'
|
| 'security'
|
||||||
| 'integrations'
|
| 'integrations'
|
||||||
| 'network'
|
| 'network'
|
||||||
@@ -21,12 +22,16 @@ export type ConfigTabId =
|
|||||||
|
|
||||||
export type ResourceTabId = 'overview' | 'security' | 'add'
|
export type ResourceTabId = 'overview' | 'security' | 'add'
|
||||||
|
|
||||||
|
export type NodeDetailTabId = 'overview' | 'healthcheck' | 'actions' | 'logs'
|
||||||
|
|
||||||
export type ResourceSectionId = 'containers' | 'vms' | 'docker'
|
export type ResourceSectionId = 'containers' | 'vms' | 'docker'
|
||||||
|
|
||||||
export type AppLocation = {
|
export type AppLocation = {
|
||||||
section: SectionId
|
section: SectionId
|
||||||
configTab: ConfigTabId
|
configTab: ConfigTabId
|
||||||
resourceTab: ResourceTabId
|
resourceTab: ResourceTabId
|
||||||
|
nodeId: string | null
|
||||||
|
nodeDetailTab: NodeDetailTabId
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONFIG_TAB_IDS: ReadonlySet<string> = new Set([
|
const CONFIG_TAB_IDS: ReadonlySet<string> = new Set([
|
||||||
@@ -34,6 +39,7 @@ const CONFIG_TAB_IDS: ReadonlySet<string> = new Set([
|
|||||||
'users',
|
'users',
|
||||||
'roles',
|
'roles',
|
||||||
'groups',
|
'groups',
|
||||||
|
'actions',
|
||||||
'security',
|
'security',
|
||||||
'integrations',
|
'integrations',
|
||||||
'network',
|
'network',
|
||||||
@@ -46,6 +52,13 @@ const RESOURCE_TAB_IDS: ReadonlySet<string> = new Set([
|
|||||||
'add',
|
'add',
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const NODE_DETAIL_TAB_IDS: ReadonlySet<string> = new Set([
|
||||||
|
'overview',
|
||||||
|
'healthcheck',
|
||||||
|
'actions',
|
||||||
|
'logs',
|
||||||
|
])
|
||||||
|
|
||||||
const RESOURCE_SECTION_IDS: ReadonlySet<string> = new Set([
|
const RESOURCE_SECTION_IDS: ReadonlySet<string> = new Set([
|
||||||
'containers',
|
'containers',
|
||||||
'vms',
|
'vms',
|
||||||
@@ -56,6 +69,8 @@ const DEFAULT_LOCATION: AppLocation = {
|
|||||||
section: 'overview',
|
section: 'overview',
|
||||||
configTab: 'overview',
|
configTab: 'overview',
|
||||||
resourceTab: 'overview',
|
resourceTab: 'overview',
|
||||||
|
nodeId: null,
|
||||||
|
nodeDetailTab: 'overview',
|
||||||
}
|
}
|
||||||
|
|
||||||
function isConfigTabId(value: string): value is ConfigTabId {
|
function isConfigTabId(value: string): value is ConfigTabId {
|
||||||
@@ -66,6 +81,10 @@ function isResourceTabId(value: string): value is ResourceTabId {
|
|||||||
return RESOURCE_TAB_IDS.has(value)
|
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 {
|
export function isResourceSectionId(value: SectionId): value is ResourceSectionId {
|
||||||
return RESOURCE_SECTION_IDS.has(value)
|
return RESOURCE_SECTION_IDS.has(value)
|
||||||
}
|
}
|
||||||
@@ -79,31 +98,52 @@ export function parseLocation(pathname: string): AppLocation {
|
|||||||
return { ...DEFAULT_LOCATION }
|
return { ...DEFAULT_LOCATION }
|
||||||
}
|
}
|
||||||
|
|
||||||
const [first, second] = segments
|
const [first, second, third] = segments
|
||||||
|
|
||||||
if (first === 'setup' && segments.length === 1) {
|
if (first === 'setup' && segments.length === 1) {
|
||||||
return { section: 'setup', configTab: 'overview', resourceTab: 'overview' }
|
return { ...DEFAULT_LOCATION, section: 'setup' }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (first === 'login' && segments.length === 1) {
|
if (first === 'login' && segments.length === 1) {
|
||||||
return { section: 'login', configTab: 'overview', resourceTab: 'overview' }
|
return { ...DEFAULT_LOCATION, section: 'login' }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (first === 'profile' && segments.length === 1) {
|
if (first === 'profile' && segments.length === 1) {
|
||||||
return { section: 'profile', configTab: 'overview', resourceTab: 'overview' }
|
return { ...DEFAULT_LOCATION, section: 'profile' }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (first === 'logs' && segments.length === 1) {
|
if (first === 'logs' && segments.length === 1) {
|
||||||
return { section: 'logs', configTab: 'overview', resourceTab: 'overview' }
|
return { ...DEFAULT_LOCATION, section: 'logs' }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (RESOURCE_SECTION_IDS.has(first)) {
|
if (RESOURCE_SECTION_IDS.has(first)) {
|
||||||
const section = first as ResourceSectionId
|
const section = first as ResourceSectionId
|
||||||
if (segments.length === 1) {
|
if (segments.length === 1) {
|
||||||
return { section, configTab: 'overview', resourceTab: 'overview' }
|
return { ...DEFAULT_LOCATION, section }
|
||||||
}
|
}
|
||||||
if (segments.length === 2 && isResourceTabId(second)) {
|
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 }
|
return { ...DEFAULT_LOCATION }
|
||||||
}
|
}
|
||||||
@@ -111,16 +151,15 @@ export function parseLocation(pathname: string): AppLocation {
|
|||||||
if (first === 'configuration') {
|
if (first === 'configuration') {
|
||||||
if (segments.length === 1) {
|
if (segments.length === 1) {
|
||||||
return {
|
return {
|
||||||
|
...DEFAULT_LOCATION,
|
||||||
section: 'configuration',
|
section: 'configuration',
|
||||||
configTab: 'overview',
|
|
||||||
resourceTab: 'overview',
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (segments.length === 2 && isConfigTabId(second)) {
|
if (segments.length === 2 && isConfigTabId(second)) {
|
||||||
return {
|
return {
|
||||||
|
...DEFAULT_LOCATION,
|
||||||
section: 'configuration',
|
section: 'configuration',
|
||||||
configTab: second,
|
configTab: second,
|
||||||
resourceTab: 'overview',
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,6 +172,8 @@ export function pathFor(
|
|||||||
section: SectionId,
|
section: SectionId,
|
||||||
configTab: ConfigTabId = 'overview',
|
configTab: ConfigTabId = 'overview',
|
||||||
resourceTab: ResourceTabId = 'overview',
|
resourceTab: ResourceTabId = 'overview',
|
||||||
|
nodeId: string | null = null,
|
||||||
|
nodeDetailTab: NodeDetailTabId = 'overview',
|
||||||
): string {
|
): string {
|
||||||
if (section === 'overview') {
|
if (section === 'overview') {
|
||||||
return '/'
|
return '/'
|
||||||
@@ -150,6 +191,12 @@ export function pathFor(
|
|||||||
return '/logs'
|
return '/logs'
|
||||||
}
|
}
|
||||||
if (isResourceSectionId(section)) {
|
if (isResourceSectionId(section)) {
|
||||||
|
if (nodeId) {
|
||||||
|
if (nodeDetailTab === 'overview') {
|
||||||
|
return `/${section}/${nodeId}`
|
||||||
|
}
|
||||||
|
return `/${section}/${nodeId}/${nodeDetailTab}`
|
||||||
|
}
|
||||||
if (resourceTab === 'overview') {
|
if (resourceTab === 'overview') {
|
||||||
return `/${section}`
|
return `/${section}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user