Compare commits

..
22 Commits
Author SHA1 Message Date
Squid aac9e82766 Add per-node health checks with status indicators and scheduled probes. 2026-07-19 22:40:21 +02:00
Squid 5307ba1a7b Align READMEs with SSH host management and current config layout. 2026-07-19 22:07:27 +02:00
Squid 7631591f30 Wire action-group variables, sudo, and command path resolution through the API and UI.
Completes run-if/set_variable and requires_sudo editing, sudoers path resolve helpers, and execute permission checks so node action groups can gate upgrades and elevate safely.
2026-07-19 22:00:14 +02:00
Squid 0e06063c1d Clarify run-if skip reasons with the resolved variable value.
Failed conditions now explain skips in plain language (e.g. UpdateCount was 0) and truncate long captures so misconfigured apt listings cannot flood the UI.
2026-07-19 21:59:35 +02:00
Squid eade2ef0c7 Highlight sudo actions in red and show a pointer cursor on expandable log rows. 2026-07-19 21:14:17 +02:00
Squid 32af91fd30 Add per-node action groups with schedules, runs, and logs.
Let operators manage ordered action groups on each host, run them manually or on a schedule over SSH, and inspect disk-backed JSON run logs from a node detail UI.
2026-07-19 20:01:11 +02:00
Squid 50568127c5 page title 2026-07-18 22:26:38 +02:00
Squid 463aa9a7a3 Add activity logs, sidebar badges, and logout with working alert colors.
Record auth audits and surface them in Activity, poll sidebar counts without resetting idle, and add a profile logout path plus theme red/green/blue tokens so failure badges render.
2026-07-18 21:43:44 +02:00
Squid e3793f380c Show a login notice when a session ends due to idle timeout.
Return a distinct session_idle error from the API and reload to /login with a clear signed-out message.
2026-07-18 20:50:52 +02:00
Squid c90a47c3ea Add Configuration Actions tab for shell and script action definitions.
Admins can manage built-in and custom actions with placeholders, ENV vars, and actions.create/update/delete permissions.
2026-07-18 19:18:13 +02:00
Squid 77be394407 Add nodes.delete with reauth, activity logs, and Administrators editing.
Allow deleting managed hosts with mandatory inline re-auth, record node mutations in an audit log with a sidebar Logs view, and let Administrators group permissions be edited from the Groups tab.
2026-07-18 16:54:13 +02:00
Squid f4dc8f63d7 Add SSH-managed node registry with connection testing and reauth.
Register hosts under Containers/VMs/Docker with encrypted key storage, and require re-authentication for sensitive account changes.
2026-07-18 16:39:10 +02:00
Squid b93b7519ec Allow editing non-Administrators group access from the Groups tab.
Load a group into the form to change scope and permissions; keep Administrators locked like delete.
2026-07-18 15:29:22 +02:00
Squid 5d6f65f3cd Style Access mode selects like other dropdowns.
Use theme-select so Access mode shows a chevron and matches Theme/Scope kind.
2026-07-18 15:24:37 +02:00
Squid 458f14e74f Fix remotedev scp failing when prior binaries are still running.
Stop remote processes with pkill -f before upload so long binary names are matched and busy executables can be overwritten.
2026-07-18 15:23:29 +02:00
Squid 2605c3b346 Add users and network settings tabs with admin protections.
List and delete accounts with last-admin and Administrators-group guards, and expose editable network settings via the configuration UI.
2026-07-18 15:18:01 +02:00
Squid f049f766d9 Fixed up the up, scope kind looks more like a dropbox 2026-07-16 22:23:24 +02:00
Squid c8f47768a3 Added user profile page, prepped for TOTP 2fa 2026-07-16 22:13:54 +02:00
Squid 63deb02d39 More webui layout, navigation, preparations of user/roles/groups/security 2026-07-16 22:00:02 +02:00
Squid 324367abd7 Base webui and backend additions 2026-07-16 21:35:38 +02:00
Squid d2b92401f1 Add dashboard shell with sidebar nav and version header.
Replace the health-check stub UI with a left menu and content panel so section navigation and frontend/backend versions are visible in the shell.
2026-07-16 20:57:47 +02:00
Squid 38b4e8fd43 Scaffolding baseline 2026-07-16 20:47:33 +02:00
134 changed files with 32561 additions and 1 deletions
+31
View File
@@ -0,0 +1,31 @@
# Dependencies
webui/node_modules/
# Build outputs
bin/
webui/dist/
*.exe
*.test
# Coverage / test artifacts
coverage/
coverage.out
*.coverprofile
webui/coverage/
# Environment
.env
.env.*
!.env.example
remotedev.mk
# IDE / OS
.idea/
.vscode/
*.swp
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
+81
View File
@@ -0,0 +1,81 @@
.PHONY: service webui dev remotedev test build clean
export PATH := $(HOME)/.local/go/bin:$(PATH)
# Optional local overrides (gitignored): REMOTEDEV_USER, REMOTEDEV_HOST, etc.
-include remotedev.mk
REMOTEDEV_DIR ?= clustercanvas-remotedev
PORT ?= 8080
WEBUI_PORT ?= 5173
# Dev-only AES key for remotedev (base64 of 32 bytes). Override in remotedev.mk if needed.
# NOT for production.
REMOTEDEV_CONFIG_KEY ?= Q2x1c3RlckNhbnZhcy1yZW1vdGVkZXYta2V5ISEhISE=
# Build SSH target from prepared user+host, or use REMOTEDEV_HOST as-is (user@host / SSH alias).
ifneq ($(strip $(REMOTEDEV_USER)),)
REMOTEDEV_SSH := $(REMOTEDEV_USER)@$(REMOTEDEV_HOST)
else
REMOTEDEV_SSH := $(REMOTEDEV_HOST)
endif
# pkill -x cannot match names longer than 15 chars (Linux /proc/*/comm). Use -f on argv.
# Stop before scp so running binaries are not busy when overwritten.
REMOTEDEV_KILL = pkill -f './clustercanvas-server( |$$)' >/dev/null 2>&1 || true; \
pkill -f './clustercanvas-webui( |$$)' >/dev/null 2>&1 || true; \
sleep 0.2
service:
cd service && go run ./cmd/server
webui:
cd webui && npm run dev
# Run API (:8080) and web UI (:5173) together; Ctrl+C stops both.
# Export CLUSTERCANVAS_CONFIG_KEY in your shell (or remotedev.mk) before first-run setup.
dev:
@trap 'kill 0' EXIT INT TERM; \
(cd service && go run ./cmd/server) & \
(cd webui && npm run dev) & \
wait
# Build locally, copy binaries + SPA to a VM over SSH, run both there (no SDKs on remote).
# Prepare remotedev.mk with REMOTEDEV_USER + REMOTEDEV_HOST, or pass them on the CLI.
# Usage: make remotedev
# or: REMOTEDEV_HOST=user@192.168.1.50 make remotedev
remotedev:
@test -n "$(REMOTEDEV_HOST)" || (echo 'Set REMOTEDEV_HOST (and optional REMOTEDEV_USER) in remotedev.mk or on the CLI'; exit 1)
mkdir -p bin
cd service && go build -o ../bin/clustercanvas-server ./cmd/server
cd service && go build -o ../bin/clustercanvas-webui ./cmd/webui
cd webui && VITE_API_BASE_URL= npm run build
# scp only needs OpenSSH on the remote (rsync is often missing on minimal VMs).
# Leave ~ unquoted so the remote shell expands REMOTEDEV_DIR=~/... correctly.
ssh "$(REMOTEDEV_SSH)" "mkdir -p $(REMOTEDEV_DIR)/web $(REMOTEDEV_DIR)/config"
ssh "$(REMOTEDEV_SSH)" "cd $(REMOTEDEV_DIR) 2>/dev/null || true; $(REMOTEDEV_KILL)"
scp bin/clustercanvas-server bin/clustercanvas-webui "$(REMOTEDEV_SSH):$(REMOTEDEV_DIR)/"
ssh "$(REMOTEDEV_SSH)" "rm -rf $(REMOTEDEV_DIR)/web && mkdir -p $(REMOTEDEV_DIR)/web"
scp -r webui/dist/. "$(REMOTEDEV_SSH):$(REMOTEDEV_DIR)/web/"
@echo "Starting on $(REMOTEDEV_SSH) — open http://<vm-ip>:$(WEBUI_PORT) (Ctrl+C stops both)"
# Kill in a separate ssh so pkill -f cannot match this start script's own argv.
ssh "$(REMOTEDEV_SSH)" "cd $(REMOTEDEV_DIR) 2>/dev/null || true; $(REMOTEDEV_KILL)"
ssh -t "$(REMOTEDEV_SSH)" "cd $(REMOTEDEV_DIR) && \
trap 'kill 0' EXIT INT TERM; \
export CLUSTERCANVAS_CONFIG_KEY='$(REMOTEDEV_CONFIG_KEY)'; \
export CLUSTERCANVAS_CONFIG_DIR=\"\$$PWD/config\"; \
PORT=$(PORT) ./clustercanvas-server -configdir=config & \
WEBUI_PORT=$(WEBUI_PORT) API_URL=http://127.0.0.1:$(PORT) ./clustercanvas-webui -webdir=web -configdir=config & \
wait"
test:
cd service && go test ./...
cd webui && npm test
build:
mkdir -p bin
cd service && go build -o ../bin/clustercanvas-server ./cmd/server
cd service && go build -o ../bin/clustercanvas-webui ./cmd/webui
cd webui && npm run build
clean:
rm -rf bin service/coverage.out webui/dist webui/coverage
+142 -1
View File
@@ -1,3 +1,144 @@
# ClusterCanvas # ClusterCanvas
A web-based dashboard for managing Proxmox VMs and LXC containers. After configuration it discovers hosts and shows perinstance details like hostname, storage and RAM usage, and status. From the same interface you can view logs, run updates, deploy changes, and perform other routine maintenance tasks. A web UI and Go API for managing remote hosts over SSH. You register hosts manually (grouped as containers, VMs, or Docker for navigation), run shell or script actions on them—including scheduled action groups—and administer users, groups, and sessions with a first-run setup wizard, optional TOTP, and audit logs.
## Features
- **SSH hosts** — Register nodes with generated or imported keys, test connectivity, and organize them under Containers / VMs / Docker categories
- **Action library** — Global shell/script actions (seeded builtins: Uptime, Disk usage, System info) plus custom actions with env vars and optional sudo
- **Action groups** — Per-node ordered items (library refs or local copies), schedules (interval or clock times), `set_variable` / `run_if`, and run logs
- **Auth & access** — Setup wizard, session cookies, group permissions, optional TOTP, and reauth for sensitive operations
- **Activity** — Node and auth audit trails, plus per-node action run history
## Layout
| Path | Role |
|------|------|
| `service/` | Go HTTP API |
| `webui/` | Vite + React + TypeScript SPA |
## Prerequisites
- Go 1.25+ (toolchain may download a newer Go as needed)
- Node.js 20+ and npm
## Configuration
Service config lives under **`/etc/ClusterCanvas/`** by default:
| File | Purpose |
|------|---------|
| `settings.json` | Setup, network, security, and groups (plain JSON) |
| `passwords.enc` | AES-GCM encrypted user credentials (argon2id hashes, TOTP secrets) |
| `sessions.enc` | AES-GCM encrypted session store |
| `secrets.enc` | AES-GCM encrypted 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):
1. `-configdir` flag — e.g. `./bin/clustercanvas-server -configdir="~/.myconfigs"`
2. `CLUSTERCANVAS_CONFIG_DIR` environment variable
3. `/etc/ClusterCanvas`
A leading `~/` in the path is expanded to the user home directory.
**Secrets encryption key:** set `CLUSTERCANVAS_CONFIG_KEY` to a base64-encoded 16-, 24-, or 32-byte key (required for first-run setup and login):
```bash
openssl rand -base64 32
export CLUSTERCANVAS_CONFIG_KEY='…paste output…'
```
For a one-off shell session you can also prefix the command:
```bash
CLUSTERCANVAS_CONFIG_KEY="$(openssl rand -base64 32)" make service
# or with make dev:
export CLUSTERCANVAS_CONFIG_KEY="$(openssl rand -base64 32)"
make dev
```
Keep the same key across restarts if you already ran setup — changing it makes existing `passwords.enc` / `sessions.enc` / `node-keys.enc` unreadable.
### First-run setup
On a clean install (`setup_completed` is false / missing), the web UI opens a setup wizard instead of the dashboard. It creates the admin user, Administrators group, network settings (listen address, optional public hostname, IP allow/deny lists), and security policy, then requires sign-in.
Session cookies use the `__Host-ClusterCanvas-Session` name with `Secure`, `HttpOnly`, `SameSite=Strict`, and `Path=/` (no `Domain`). Production should set a **public hostname** and terminate TLS at a reverse proxy so browsers reach the UI over HTTPS. Listen address changes apply on the next process restart.
## Run
Start API and web UI together (API `:8080`, UI `:5173`; Ctrl+C stops both):
```bash
make dev
```
Or start them separately:
```bash
make service # API on :8080 (override with PORT)
make webui # UI on :5173 (proxies /health and /api)
```
Custom config directory for the API:
```bash
cd service && go run ./cmd/server -configdir="$HOME/.myconfigs"
```
Optional: set `VITE_API_BASE_URL` (defaults to `http://localhost:8080`) if you call the API without the Vite proxy. An empty value means same-origin requests (used by `make remotedev`).
## Remote dev (VM without SDKs)
Build on your machine, copy runnables over SSH, and run **both** processes on a remote host (no Go/Node required there—only OpenSSH).
Prepare host/user once (recommended):
```bash
cp remotedev.mk.example remotedev.mk
# edit REMOTEDEV_USER and REMOTEDEV_HOST in remotedev.mk
make remotedev
```
Or pass them on the CLI:
```bash
REMOTEDEV_USER=youruser REMOTEDEV_HOST=192.168.1.50 make remotedev
# or combined: REMOTEDEV_HOST=youruser@192.168.1.50 make remotedev
```
| Env | Default | Meaning |
|-----|---------|---------|
| `REMOTEDEV_HOST` | _(required)_ | Host/IP, `user@host`, or an SSH config Host alias |
| `REMOTEDEV_USER` | _(optional)_ | Username; combined as `user@host` when set |
| `REMOTEDEV_DIR` | `clustercanvas-remotedev` | Remote directory (`~/…` is fine; relative paths are under the remote home) |
| `REMOTEDEV_CONFIG_KEY` | _(baked-in remotedev key)_ | Dev-only `CLUSTERCANVAS_CONFIG_KEY` exported on the VM (override in `remotedev.mk`) |
| `PORT` | `8080` | API listen port on the VM (all interfaces) |
| `WEBUI_PORT` | `5173` | Web UI listen port on the VM (all interfaces) |
What lands on the VM: `clustercanvas-server`, `clustercanvas-webui`, and `web/` (built SPA). Config is stored under `$(REMOTEDEV_DIR)/config` with the remotedev encryption key so the setup wizard works without extra env setup. Transfer uses `scp` (OpenSSH only on the remote—no `rsync` required). Re-running `make remotedev` stops any previous instance on the VM before uploading so binaries are not busy. The webui binary serves the SPA and proxies `/api` and `/health` to the API. Open `http://<vm-ip>:5173`. Ctrl+C stops both remote processes. A reverse proxy in front of these ports is optional and separate.
**Do not use `REMOTEDEV_CONFIG_KEY` (or its default) in production** — generate a unique key with `openssl rand -base64 32` instead.
## Test
```bash
make test
```
This runs `go test ./...` in `service/` and Vitest in `webui/`.
## Build
```bash
make build
```
Produces `bin/clustercanvas-server`, `bin/clustercanvas-webui`, and `webui/dist/`.
+12
View File
@@ -0,0 +1,12 @@
# Local remotedev settings (copy to remotedev.mk — that file is gitignored).
# Then run: make remotedev
REMOTEDEV_USER = youruser
REMOTEDEV_HOST = 192.168.1.50
# Optional:
# REMOTEDEV_DIR = clustercanvas-remotedev
# PORT = 8080
# WEBUI_PORT = 5173
# Override the baked-in remotedev encryption key (dev only):
# REMOTEDEV_CONFIG_KEY = $(shell openssl rand -base64 32)
+71
View File
@@ -0,0 +1,71 @@
package main
import (
"context"
"errors"
"flag"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/api"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
func main() {
configDirFlag := flag.String(
"configdir",
"",
"config directory (default /etc/ClusterCanvas; overrides CLUSTERCANVAS_CONFIG_DIR)",
)
flag.Parse()
configDir, err := settings.ResolveDir(*configDirFlag)
if err != nil {
log.Fatalf("config dir: %v", err)
}
log.Printf("config directory: %s", configDir)
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
listenHost := settings.ListenHost(configDir)
listenAddr := listenHost + ":" + port
handler, app := api.NewRouterWithApp(configDir)
server := &http.Server{
Addr: listenAddr,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
log.Printf("clustercanvas service listening on http://%s", listenAddr)
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("listen: %v", err)
}
}()
stopSignals := make(chan os.Signal, 1)
signal.Notify(stopSignals, syscall.SIGINT, syscall.SIGTERM)
<-stopSignals
if app.Executor != nil {
app.Executor.StopScheduler()
}
if app.HealthChecker != nil {
app.HealthChecker.StopScheduler()
}
shutdownContext, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(shutdownContext); err != nil {
log.Fatalf("shutdown: %v", err)
}
log.Print("server stopped")
}
+73
View File
@@ -0,0 +1,73 @@
package main
import (
"context"
"errors"
"flag"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/websrv"
)
func main() {
webDirFlag := flag.String("webdir", "web", "directory of built SPA assets")
configDirFlag := flag.String(
"configdir",
"",
"config directory for listen address (default /etc/ClusterCanvas; overrides CLUSTERCANVAS_CONFIG_DIR)",
)
flag.Parse()
configDir, err := settings.ResolveDir(*configDirFlag)
if err != nil {
log.Fatalf("config dir: %v", err)
}
port := os.Getenv("WEBUI_PORT")
if port == "" {
port = "5173"
}
apiURL := os.Getenv("API_URL")
if apiURL == "" {
apiURL = "http://127.0.0.1:8080"
}
handler, err := websrv.NewHandler(*webDirFlag, apiURL)
if err != nil {
log.Fatalf("webui handler: %v", err)
}
listenHost := settings.ListenHost(configDir)
listenAddr := listenHost + ":" + port
server := &http.Server{
Addr: listenAddr,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
log.Printf("clustercanvas webui listening on http://%s (webdir=%s; API proxy %s)", listenAddr, *webDirFlag, apiURL)
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("listen: %v", err)
}
}()
stopSignals := make(chan os.Signal, 1)
signal.Notify(stopSignals, syscall.SIGINT, syscall.SIGTERM)
<-stopSignals
shutdownContext, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(shutdownContext); err != nil {
log.Fatalf("shutdown: %v", err)
}
log.Print("webui stopped")
}
+13
View File
@@ -0,0 +1,13 @@
module codeberg.org/SquidSE/ClusterCanvas/service
go 1.25.0
require (
github.com/pquerna/otp v1.5.0
golang.org/x/crypto v0.54.0
)
require (
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
golang.org/x/sys v0.47.0 // indirect
)
+15
View File
@@ -0,0 +1,15 @@
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
@@ -0,0 +1,871 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"os"
"strings"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/runner"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
type actionGroupsResponse struct {
Groups []settings.NodeActionGroup `json:"groups"`
}
type actionGroupResponse struct {
Group settings.NodeActionGroup `json:"group"`
}
type actionRunResponse struct {
Run settings.ActionGroupRunRecord `json:"run"`
}
type actionLogsListResponse struct {
Files []settings.ActionLogFileInfo `json:"files"`
}
type actionLogFileResponse struct {
Log settings.ActionLogFile `json:"log"`
}
type createActionGroupRequest struct {
Name string `json:"name"`
Schedule *settings.ActionGroupSchedule `json:"schedule"`
}
type patchActionGroupRequest struct {
Name *string `json:"name"`
Schedule *settings.ActionGroupSchedule `json:"schedule"`
}
type createActionGroupItemRequest struct {
Source settings.ActionItemSource `json:"source"`
LibraryActionID string `json:"library_action_id"`
Name string `json:"name"`
Description string `json:"description"`
Kind settings.ActionKind `json:"kind"`
Body string `json:"body"`
Env []settings.ActionEnvVar `json:"env"`
RequiresSudo bool `json:"requires_sudo"`
SetVariable string `json:"set_variable"`
RunIf string `json:"run_if"`
}
type patchActionGroupItemRequest struct {
Name *string `json:"name"`
Description *string `json:"description"`
Kind *settings.ActionKind `json:"kind"`
Body *string `json:"body"`
Env *[]settings.ActionEnvVar `json:"env"`
RequiresSudo *bool `json:"requires_sudo"`
SetVariable *string `json:"set_variable"`
RunIf *string `json:"run_if"`
}
type reorderActionGroupItemsRequest struct {
ItemIDs []string `json:"item_ids"`
}
func (app *App) actionGroupsListHandler(writer http.ResponseWriter, request *http.Request) {
node, ok := app.loadAccessibleNode(writer, request)
if !ok {
return
}
store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, actionGroupsResponse{Groups: settings.GroupsForNode(store, node.ID)})
}
func (app *App) actionGroupsCreateHandler(writer http.ResponseWriter, request *http.Request) {
if err := authorizeActionsCreate(request, app.ConfigDir); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
node, ok := app.loadAccessibleNode(writer, request)
if !ok {
return
}
var payload createActionGroupRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
name := strings.TrimSpace(payload.Name)
if name == "" {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "group name is required"})
return
}
schedule := settings.ActionGroupSchedule{
Enabled: false,
Kind: settings.ActionGroupScheduleInterval,
Times: []settings.ActionGroupTimeSpec{},
}
if payload.Schedule != nil {
schedule = *payload.Schedule
if schedule.Times == nil {
schedule.Times = []settings.ActionGroupTimeSpec{}
}
}
if err := validateSchedule(schedule); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
groupID, err := auth.NewUUID()
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
now := time.Now().UTC()
group := settings.NodeActionGroup{
ID: groupID,
NodeID: node.ID,
Name: name,
Schedule: schedule,
Items: []settings.NodeActionItem{},
CreatedAt: now,
UpdatedAt: now,
}
store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
store.Groups = append(store.Groups, group)
if err := settings.SaveNodeActionGroups(app.ConfigDir, store); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, actionGroupResponse{Group: group})
}
func (app *App) actionGroupsPatchHandler(writer http.ResponseWriter, request *http.Request) {
if err := authorizeActionsUpdate(request, app.ConfigDir); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
node, ok := app.loadAccessibleNode(writer, request)
if !ok {
return
}
groupID := strings.TrimSpace(request.PathValue("gid"))
var payload patchActionGroupRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
index := settings.FindNodeActionGroupIndex(store.Groups, groupID)
if index < 0 || store.Groups[index].NodeID != node.ID {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action group not found"})
return
}
group := store.Groups[index]
if payload.Name != nil {
name := strings.TrimSpace(*payload.Name)
if name == "" {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "group name is required"})
return
}
group.Name = name
}
if payload.Schedule != nil {
schedule := *payload.Schedule
if schedule.Times == nil {
schedule.Times = []settings.ActionGroupTimeSpec{}
}
if err := validateSchedule(schedule); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
group.Schedule = schedule
}
group.UpdatedAt = time.Now().UTC()
store.Groups[index] = group
if err := settings.SaveNodeActionGroups(app.ConfigDir, store); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, actionGroupResponse{Group: group})
}
func (app *App) actionGroupsDeleteHandler(writer http.ResponseWriter, request *http.Request) {
if err := authorizeActionsDelete(request, app.ConfigDir); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
node, ok := app.loadAccessibleNode(writer, request)
if !ok {
return
}
groupID := strings.TrimSpace(request.PathValue("gid"))
store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
index := settings.FindNodeActionGroupIndex(store.Groups, groupID)
if index < 0 || store.Groups[index].NodeID != node.ID {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action group not found"})
return
}
store.Groups = append(store.Groups[:index], store.Groups[index+1:]...)
if err := settings.SaveNodeActionGroups(app.ConfigDir, store); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, actionGroupsResponse{Groups: settings.GroupsForNode(store, node.ID)})
}
func (app *App) actionGroupItemsCreateHandler(writer http.ResponseWriter, request *http.Request) {
if err := authorizeActionsCreate(request, app.ConfigDir); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
node, ok := app.loadAccessibleNode(writer, request)
if !ok {
return
}
groupID := strings.TrimSpace(request.PathValue("gid"))
var payload createActionGroupItemRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
item, err := buildNodeActionItem(payload)
if err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
index := settings.FindNodeActionGroupIndex(store.Groups, groupID)
if index < 0 || store.Groups[index].NodeID != node.ID {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action group not found"})
return
}
if item.Source == settings.ActionItemSourceLibrary {
library, libErr := settings.LoadActionsOrSeed(app.ConfigDir)
if libErr != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: libErr.Error()})
return
}
found := false
for _, action := range library.Actions {
if action.ID == item.LibraryActionID {
found = true
break
}
}
if !found {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "library action not found"})
return
}
}
store.Groups[index].Items = append(store.Groups[index].Items, item)
store.Groups[index].UpdatedAt = time.Now().UTC()
if err := settings.SaveNodeActionGroups(app.ConfigDir, store); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, actionGroupResponse{Group: store.Groups[index]})
}
func (app *App) actionGroupItemsPatchHandler(writer http.ResponseWriter, request *http.Request) {
if err := authorizeActionsUpdate(request, app.ConfigDir); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
node, ok := app.loadAccessibleNode(writer, request)
if !ok {
return
}
groupID := strings.TrimSpace(request.PathValue("gid"))
itemID := strings.TrimSpace(request.PathValue("iid"))
var payload patchActionGroupItemRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
groupIndex := settings.FindNodeActionGroupIndex(store.Groups, groupID)
if groupIndex < 0 || store.Groups[groupIndex].NodeID != node.ID {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action group not found"})
return
}
itemIndex := settings.FindNodeActionItemIndex(store.Groups[groupIndex].Items, itemID)
if itemIndex < 0 {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action item not found"})
return
}
item := store.Groups[groupIndex].Items[itemIndex]
editingBodyFields := payload.Name != nil || payload.Description != nil || payload.Kind != nil || payload.Body != nil || payload.Env != nil || payload.RequiresSudo != nil
if item.Source != settings.ActionItemSourceLocal && editingBodyFields {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "library actions must be cloned before editing"})
return
}
if item.Source == settings.ActionItemSourceLocal {
if payload.Name != nil {
item.Name = strings.TrimSpace(*payload.Name)
}
if payload.Description != nil {
item.Description = strings.TrimSpace(*payload.Description)
}
if payload.Kind != nil {
item.Kind = *payload.Kind
}
if payload.Body != nil {
item.Body = strings.TrimSpace(*payload.Body)
}
if payload.Env != nil {
item.Env = *payload.Env
}
if payload.RequiresSudo != nil {
item.RequiresSudo = *payload.RequiresSudo
}
if item.Env == nil {
item.Env = []settings.ActionEnvVar{}
}
if err := validateActionFields(item.Name, item.Kind, item.Body, item.Env); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
}
if payload.SetVariable != nil {
setVariable, err := normalizeSetVariable(*payload.SetVariable)
if err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
item.SetVariable = setVariable
}
if payload.RunIf != nil {
runIf, err := normalizeRunIf(*payload.RunIf)
if err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
item.RunIf = runIf
}
store.Groups[groupIndex].Items[itemIndex] = item
store.Groups[groupIndex].UpdatedAt = time.Now().UTC()
if err := settings.SaveNodeActionGroups(app.ConfigDir, store); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, actionGroupResponse{Group: store.Groups[groupIndex]})
}
func (app *App) actionGroupItemsCloneHandler(writer http.ResponseWriter, request *http.Request) {
if err := authorizeActionsCreate(request, app.ConfigDir); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
node, ok := app.loadAccessibleNode(writer, request)
if !ok {
return
}
groupID := strings.TrimSpace(request.PathValue("gid"))
itemID := strings.TrimSpace(request.PathValue("iid"))
store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
groupIndex := settings.FindNodeActionGroupIndex(store.Groups, groupID)
if groupIndex < 0 || store.Groups[groupIndex].NodeID != node.ID {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action group not found"})
return
}
itemIndex := settings.FindNodeActionItemIndex(store.Groups[groupIndex].Items, itemID)
if itemIndex < 0 {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action item not found"})
return
}
item := store.Groups[groupIndex].Items[itemIndex]
if item.Source != settings.ActionItemSourceLibrary {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "only library actions can be cloned"})
return
}
library, err := settings.LoadActionsOrSeed(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
var libraryAction settings.Action
found := false
for _, action := range library.Actions {
if action.ID == item.LibraryActionID {
libraryAction = action
found = true
break
}
}
if !found {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "library action not found"})
return
}
env := libraryAction.Env
if env == nil {
env = []settings.ActionEnvVar{}
}
item.Source = settings.ActionItemSourceLocal
item.LibraryActionID = ""
item.Name = libraryAction.Name
item.Description = libraryAction.Description
item.Kind = libraryAction.Kind
item.Body = libraryAction.Body
item.Env = env
item.RequiresSudo = libraryAction.RequiresSudo
store.Groups[groupIndex].Items[itemIndex] = item
store.Groups[groupIndex].UpdatedAt = time.Now().UTC()
if err := settings.SaveNodeActionGroups(app.ConfigDir, store); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, actionGroupResponse{Group: store.Groups[groupIndex]})
}
func (app *App) actionGroupItemsDeleteHandler(writer http.ResponseWriter, request *http.Request) {
if err := authorizeActionsDelete(request, app.ConfigDir); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
node, ok := app.loadAccessibleNode(writer, request)
if !ok {
return
}
groupID := strings.TrimSpace(request.PathValue("gid"))
itemID := strings.TrimSpace(request.PathValue("iid"))
store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
groupIndex := settings.FindNodeActionGroupIndex(store.Groups, groupID)
if groupIndex < 0 || store.Groups[groupIndex].NodeID != node.ID {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action group not found"})
return
}
itemIndex := settings.FindNodeActionItemIndex(store.Groups[groupIndex].Items, itemID)
if itemIndex < 0 {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action item not found"})
return
}
items := store.Groups[groupIndex].Items
store.Groups[groupIndex].Items = append(items[:itemIndex], items[itemIndex+1:]...)
store.Groups[groupIndex].UpdatedAt = time.Now().UTC()
if err := settings.SaveNodeActionGroups(app.ConfigDir, store); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, actionGroupResponse{Group: store.Groups[groupIndex]})
}
func (app *App) actionGroupItemsOrderHandler(writer http.ResponseWriter, request *http.Request) {
if err := authorizeActionsUpdate(request, app.ConfigDir); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
node, ok := app.loadAccessibleNode(writer, request)
if !ok {
return
}
groupID := strings.TrimSpace(request.PathValue("gid"))
var payload reorderActionGroupItemsRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
groupIndex := settings.FindNodeActionGroupIndex(store.Groups, groupID)
if groupIndex < 0 || store.Groups[groupIndex].NodeID != node.ID {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action group not found"})
return
}
current := store.Groups[groupIndex].Items
if len(payload.ItemIDs) != len(current) {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "item_ids must include every item exactly once"})
return
}
byID := make(map[string]settings.NodeActionItem, len(current))
for _, item := range current {
byID[item.ID] = item
}
reordered := make([]settings.NodeActionItem, 0, len(payload.ItemIDs))
seen := make(map[string]struct{}, len(payload.ItemIDs))
for _, itemID := range payload.ItemIDs {
item, exists := byID[itemID]
if !exists {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "unknown item id in order list"})
return
}
if _, dup := seen[itemID]; dup {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "duplicate item id in order list"})
return
}
seen[itemID] = struct{}{}
reordered = append(reordered, item)
}
store.Groups[groupIndex].Items = reordered
store.Groups[groupIndex].UpdatedAt = time.Now().UTC()
if err := settings.SaveNodeActionGroups(app.ConfigDir, store); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, actionGroupResponse{Group: store.Groups[groupIndex]})
}
func (app *App) actionGroupsRunHandler(writer http.ResponseWriter, request *http.Request) {
if err := authorizeActionsExecute(request, app.ConfigDir); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
node, ok := app.loadAccessibleNode(writer, request)
if !ok {
return
}
groupID := strings.TrimSpace(request.PathValue("gid"))
group, found := app.findGroupForNode(node.ID, groupID)
if !found {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action group not found"})
return
}
if app.Executor == nil {
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: "executor unavailable"})
return
}
actor := ""
if user, ok := UserFromContext(request.Context()); ok {
actor = user.Username
}
record, err := app.Executor.RunGroup(group, settings.ActionRunTriggerManual, actor)
if err != nil {
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, actionRunResponse{Run: record})
}
func (app *App) actionGroupItemsRunHandler(writer http.ResponseWriter, request *http.Request) {
if err := authorizeActionsExecute(request, app.ConfigDir); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
node, ok := app.loadAccessibleNode(writer, request)
if !ok {
return
}
groupID := strings.TrimSpace(request.PathValue("gid"))
itemID := strings.TrimSpace(request.PathValue("iid"))
group, found := app.findGroupForNode(node.ID, groupID)
if !found {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action group not found"})
return
}
itemIndex := settings.FindNodeActionItemIndex(group.Items, itemID)
if itemIndex < 0 {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "action item not found"})
return
}
if app.Executor == nil {
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: "executor unavailable"})
return
}
actor := ""
if user, ok := UserFromContext(request.Context()); ok {
actor = user.Username
}
record, err := app.Executor.RunItem(group, group.Items[itemIndex], settings.ActionRunTriggerManual, actor)
if err != nil {
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, actionRunResponse{Run: record})
}
func (app *App) actionLogsListHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.authorizeLogsRead(request); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
node, ok := app.loadAccessibleNode(writer, request)
if !ok {
return
}
files, err := settings.ListActionLogFiles(app.ConfigDir, node.ID)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, actionLogsListResponse{Files: files})
}
func (app *App) actionLogsGetHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.authorizeLogsRead(request); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
node, ok := app.loadAccessibleNode(writer, request)
if !ok {
return
}
filename := strings.TrimSpace(request.PathValue("filename"))
logFile, err := settings.ReadActionLogFileByName(app.ConfigDir, node.ID, filename)
if err != nil {
if strings.Contains(err.Error(), "invalid log filename") {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
if errors.Is(err, os.ErrNotExist) {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "log file not found"})
return
}
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "log file not found"})
return
}
writeJSON(writer, http.StatusOK, actionLogFileResponse{Log: logFile})
}
func (app *App) loadAccessibleNode(writer http.ResponseWriter, request *http.Request) (settings.Node, bool) {
if err := app.authorizeNodesRead(request); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return settings.Node{}, false
}
user, ok := UserFromContext(request.Context())
if !ok {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "authentication required"})
return settings.Node{}, false
}
nodeID := strings.TrimSpace(request.PathValue("id"))
if nodeID == "" {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "missing node id"})
return settings.Node{}, false
}
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return settings.Node{}, false
}
store, err := settings.LoadNodesOrEmpty(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return settings.Node{}, false
}
for _, node := range store.Nodes {
if node.ID != nodeID {
continue
}
if !userCanAccessNode(user, settingsPayload.Groups, node) {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: "permission denied"})
return settings.Node{}, false
}
return node, true
}
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "node not found"})
return settings.Node{}, false
}
func (app *App) findGroupForNode(nodeID string, groupID string) (settings.NodeActionGroup, bool) {
store, err := settings.LoadNodeActionGroupsOrEmpty(app.ConfigDir)
if err != nil {
return settings.NodeActionGroup{}, false
}
index := settings.FindNodeActionGroupIndex(store.Groups, groupID)
if index < 0 || store.Groups[index].NodeID != nodeID {
return settings.NodeActionGroup{}, false
}
return store.Groups[index], true
}
func buildNodeActionItem(payload createActionGroupItemRequest) (settings.NodeActionItem, error) {
itemID, err := auth.NewUUID()
if err != nil {
return settings.NodeActionItem{}, err
}
setVariable, err := normalizeSetVariable(payload.SetVariable)
if err != nil {
return settings.NodeActionItem{}, err
}
runIf, err := normalizeRunIf(payload.RunIf)
if err != nil {
return settings.NodeActionItem{}, err
}
switch payload.Source {
case settings.ActionItemSourceLibrary:
libraryID := strings.TrimSpace(payload.LibraryActionID)
if libraryID == "" {
return settings.NodeActionItem{}, errors.New("library_action_id is required")
}
return settings.NodeActionItem{
ID: itemID,
Source: settings.ActionItemSourceLibrary,
LibraryActionID: libraryID,
Env: []settings.ActionEnvVar{},
SetVariable: setVariable,
RunIf: runIf,
}, nil
case settings.ActionItemSourceLocal:
env := payload.Env
if env == nil {
env = []settings.ActionEnvVar{}
}
name := strings.TrimSpace(payload.Name)
body := strings.TrimSpace(payload.Body)
if err := validateActionFields(name, payload.Kind, body, env); err != nil {
return settings.NodeActionItem{}, err
}
return settings.NodeActionItem{
ID: itemID,
Source: settings.ActionItemSourceLocal,
Name: name,
Description: strings.TrimSpace(payload.Description),
Kind: payload.Kind,
Body: body,
Env: env,
RequiresSudo: payload.RequiresSudo,
SetVariable: setVariable,
RunIf: runIf,
}, nil
default:
return settings.NodeActionItem{}, errors.New("source must be library or local")
}
}
func normalizeSetVariable(raw string) (string, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return "", nil
}
if strings.HasPrefix(trimmed, "$") {
trimmed = strings.TrimPrefix(trimmed, "$")
trimmed = strings.TrimSpace(trimmed)
}
if err := runner.ValidateVariableName(trimmed); err != nil {
return "", err
}
return trimmed, nil
}
func normalizeRunIf(raw string) (string, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return "", nil
}
if err := runner.ValidateCondition(trimmed); err != nil {
return "", err
}
return trimmed, nil
}
func validateSchedule(schedule settings.ActionGroupSchedule) error {
switch schedule.Kind {
case settings.ActionGroupScheduleInterval:
if schedule.Enabled && schedule.EverySeconds < 1 {
return errors.New("every_seconds must be at least 1 when interval schedule is enabled")
}
return nil
case settings.ActionGroupScheduleTimes:
if schedule.Enabled && len(schedule.Times) == 0 {
return errors.New("at least one time is required when times schedule is enabled")
}
for _, spec := range schedule.Times {
hour, minute, ok := parseScheduleHHMM(spec.Time)
if !ok {
return errors.New("time must be HH:MM")
}
_ = hour
_ = minute
for _, day := range spec.DaysOfWeek {
if day < 0 || day > 6 {
return errors.New("days_of_week values must be 0-6")
}
}
}
return nil
case "":
return errors.New("schedule kind is required")
default:
return errors.New("schedule kind must be interval or times")
}
}
func parseScheduleHHMM(value string) (hour int, minute int, ok bool) {
parts := strings.Split(strings.TrimSpace(value), ":")
if len(parts) != 2 {
return 0, 0, false
}
if _, err := fmtSscanf(parts[0], &hour); err != nil {
return 0, 0, false
}
if _, err := fmtSscanf(parts[1], &minute); err != nil {
return 0, 0, false
}
if hour < 0 || hour > 23 || minute < 0 || minute > 59 {
return 0, 0, false
}
return hour, minute, true
}
func fmtSscanf(input string, dest *int) (int, error) {
n := 0
for _, ch := range input {
if ch < '0' || ch > '9' {
return 0, errors.New("not a number")
}
n = n*10 + int(ch-'0')
}
if input == "" {
return 0, errors.New("empty")
}
*dest = n
return 1, nil
}
@@ -0,0 +1,615 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
func seedNodeForActions(t *testing.T, configDir string) settings.Node {
t.Helper()
node := settings.Node{
ID: "11111111-1111-4111-8111-111111111111",
Kind: settings.NodeKindContainer,
Name: "box-1",
HostIP: "127.0.0.1",
Username: "root",
GroupName: settings.AdministratorsGroupName,
PublicKey: "ssh-ed25519 AAAA",
KeyAlgo: "ed25519",
CreatedAt: time.Now().UTC(),
}
if err := settings.SaveNodes(configDir, settings.NodeStore{Nodes: []settings.Node{node}}); err != nil {
t.Fatalf("SaveNodes: %v", err)
}
return node
}
func TestActionGroupsCRUDCloneReorderAndAuthz(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
node := seedNodeForActions(t, configDir)
if _, err := settings.LoadActionsOrSeed(configDir); err != nil {
t.Fatalf("LoadActionsOrSeed: %v", err)
}
router := NewRouter(configDir)
createBody, _ := json.Marshal(map[string]any{
"name": "Nightly",
"schedule": map[string]any{
"enabled": false,
"kind": "interval",
"every_seconds": 3600,
"times": []any{},
},
})
createReq := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/nodes/"+node.ID+"/action-groups",
bytes.NewReader(createBody),
),
cookie,
)
createRec := httptest.NewRecorder()
router.ServeHTTP(createRec, createReq)
if createRec.Code != http.StatusOK {
t.Fatalf("create group status=%d body=%s", createRec.Code, createRec.Body.String())
}
var created actionGroupResponse
if err := json.Unmarshal(createRec.Body.Bytes(), &created); err != nil {
t.Fatalf("decode create: %v", err)
}
groupID := created.Group.ID
addLibBody, _ := json.Marshal(map[string]any{
"source": "library",
"library_action_id": settings.BuiltinActionUptimeID,
})
addReq := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items",
bytes.NewReader(addLibBody),
),
cookie,
)
addRec := httptest.NewRecorder()
router.ServeHTTP(addRec, addReq)
if addRec.Code != http.StatusOK {
t.Fatalf("add item status=%d body=%s", addRec.Code, addRec.Body.String())
}
var withItem actionGroupResponse
if err := json.Unmarshal(addRec.Body.Bytes(), &withItem); err != nil {
t.Fatalf("decode add: %v", err)
}
if len(withItem.Group.Items) != 1 || withItem.Group.Items[0].Source != settings.ActionItemSourceLibrary {
t.Fatalf("items = %#v", withItem.Group.Items)
}
itemID := withItem.Group.Items[0].ID
addCustomBody, _ := json.Marshal(map[string]any{
"source": "local",
"name": "Echo",
"kind": "shell",
"body": "echo hi",
"env": []any{},
})
addCustomReq := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items",
bytes.NewReader(addCustomBody),
),
cookie,
)
addCustomRec := httptest.NewRecorder()
router.ServeHTTP(addCustomRec, addCustomReq)
if addCustomRec.Code != http.StatusOK {
t.Fatalf("add custom status=%d body=%s", addCustomRec.Code, addCustomRec.Body.String())
}
var withTwo actionGroupResponse
_ = json.Unmarshal(addCustomRec.Body.Bytes(), &withTwo)
if len(withTwo.Group.Items) != 2 {
t.Fatalf("expected 2 items, got %d", len(withTwo.Group.Items))
}
customID := withTwo.Group.Items[1].ID
orderBody, _ := json.Marshal(map[string]any{
"item_ids": []string{customID, itemID},
})
orderReq := withSession(
httptest.NewRequest(
http.MethodPut,
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items/order",
bytes.NewReader(orderBody),
),
cookie,
)
orderRec := httptest.NewRecorder()
router.ServeHTTP(orderRec, orderReq)
if orderRec.Code != http.StatusOK {
t.Fatalf("reorder status=%d body=%s", orderRec.Code, orderRec.Body.String())
}
var reordered actionGroupResponse
_ = json.Unmarshal(orderRec.Body.Bytes(), &reordered)
if reordered.Group.Items[0].ID != customID || reordered.Group.Items[1].ID != itemID {
t.Fatalf("order = %#v", reordered.Group.Items)
}
cloneReq := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items/"+itemID+"/clone",
nil,
),
cookie,
)
cloneRec := httptest.NewRecorder()
router.ServeHTTP(cloneRec, cloneReq)
if cloneRec.Code != http.StatusOK {
t.Fatalf("clone status=%d body=%s", cloneRec.Code, cloneRec.Body.String())
}
var cloned actionGroupResponse
_ = json.Unmarshal(cloneRec.Body.Bytes(), &cloned)
var clonedItem settings.NodeActionItem
for _, item := range cloned.Group.Items {
if item.ID == itemID {
clonedItem = item
break
}
}
if clonedItem.Source != settings.ActionItemSourceLocal || clonedItem.Name != "Uptime" {
t.Fatalf("cloned item = %#v", clonedItem)
}
if clonedItem.RequiresSudo {
t.Fatalf("builtin uptime clone should not require sudo: %#v", clonedItem)
}
// Forbidden without actions.create for a limited user session is covered below via groups.
listReq := withSession(
httptest.NewRequest(http.MethodGet, "/api/v1/nodes/"+node.ID+"/action-groups", nil),
cookie,
)
listRec := httptest.NewRecorder()
router.ServeHTTP(listRec, listReq)
if listRec.Code != http.StatusOK {
t.Fatalf("list status=%d", listRec.Code)
}
logsReq := withSession(
httptest.NewRequest(http.MethodGet, "/api/v1/nodes/"+node.ID+"/action-logs", nil),
cookie,
)
logsRec := httptest.NewRecorder()
router.ServeHTTP(logsRec, logsReq)
if logsRec.Code != http.StatusOK {
t.Fatalf("logs list status=%d body=%s", logsRec.Code, logsRec.Body.String())
}
}
func TestActionGroupItemVariablesCreateAndPatch(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
node := seedNodeForActions(t, configDir)
if _, err := settings.LoadActionsOrSeed(configDir); err != nil {
t.Fatalf("LoadActionsOrSeed: %v", err)
}
router := NewRouter(configDir)
createBody, _ := json.Marshal(map[string]any{
"name": "Vars",
"schedule": map[string]any{
"enabled": false,
"kind": "interval",
"times": []any{},
},
})
createReq := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/nodes/"+node.ID+"/action-groups",
bytes.NewReader(createBody),
),
cookie,
)
createRec := httptest.NewRecorder()
router.ServeHTTP(createRec, createReq)
if createRec.Code != http.StatusOK {
t.Fatalf("create group status=%d body=%s", createRec.Code, createRec.Body.String())
}
var created actionGroupResponse
if err := json.Unmarshal(createRec.Body.Bytes(), &created); err != nil {
t.Fatalf("decode create: %v", err)
}
groupID := created.Group.ID
addLocalBody, _ := json.Marshal(map[string]any{
"source": "local",
"name": "Count",
"kind": "shell",
"body": "echo 3",
"env": []any{},
"set_variable": "UpdateCount",
"run_if": "",
})
addLocalReq := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items",
bytes.NewReader(addLocalBody),
),
cookie,
)
addLocalRec := httptest.NewRecorder()
router.ServeHTTP(addLocalRec, addLocalReq)
if addLocalRec.Code != http.StatusOK {
t.Fatalf("add local status=%d body=%s", addLocalRec.Code, addLocalRec.Body.String())
}
var withLocal actionGroupResponse
if err := json.Unmarshal(addLocalRec.Body.Bytes(), &withLocal); err != nil {
t.Fatalf("decode local: %v", err)
}
if withLocal.Group.Items[0].SetVariable != "UpdateCount" {
t.Fatalf("set_variable = %#v", withLocal.Group.Items[0])
}
localID := withLocal.Group.Items[0].ID
addLibBody, _ := json.Marshal(map[string]any{
"source": "library",
"library_action_id": settings.BuiltinActionUptimeID,
"set_variable": "$Bad-Name",
})
badLibReq := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items",
bytes.NewReader(addLibBody),
),
cookie,
)
badLibRec := httptest.NewRecorder()
router.ServeHTTP(badLibRec, badLibReq)
if badLibRec.Code != http.StatusBadRequest {
t.Fatalf("expected bad variable name, got %d body=%s", badLibRec.Code, badLibRec.Body.String())
}
addLibOKBody, _ := json.Marshal(map[string]any{
"source": "library",
"library_action_id": settings.BuiltinActionUptimeID,
})
addLibReq := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items",
bytes.NewReader(addLibOKBody),
),
cookie,
)
addLibRec := httptest.NewRecorder()
router.ServeHTTP(addLibRec, addLibReq)
if addLibRec.Code != http.StatusOK {
t.Fatalf("add library status=%d body=%s", addLibRec.Code, addLibRec.Body.String())
}
var withLib actionGroupResponse
_ = json.Unmarshal(addLibRec.Body.Bytes(), &withLib)
var libID string
for _, item := range withLib.Group.Items {
if item.Source == settings.ActionItemSourceLibrary {
libID = item.ID
break
}
}
if libID == "" {
t.Fatal("library item missing")
}
patchLibBody, _ := json.Marshal(map[string]any{
"run_if": "if $UpdateCount >= 1",
"set_variable": "UptimeOut",
})
patchLibReq := withSession(
httptest.NewRequest(
http.MethodPatch,
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items/"+libID,
bytes.NewReader(patchLibBody),
),
cookie,
)
patchLibRec := httptest.NewRecorder()
router.ServeHTTP(patchLibRec, patchLibReq)
if patchLibRec.Code != http.StatusOK {
t.Fatalf("patch library vars status=%d body=%s", patchLibRec.Code, patchLibRec.Body.String())
}
var patchedLib actionGroupResponse
_ = json.Unmarshal(patchLibRec.Body.Bytes(), &patchedLib)
var libItem settings.NodeActionItem
for _, item := range patchedLib.Group.Items {
if item.ID == libID {
libItem = item
break
}
}
if libItem.RunIf != "if $UpdateCount >= 1" || libItem.SetVariable != "UptimeOut" {
t.Fatalf("library vars = %#v", libItem)
}
badBodyPatch, _ := json.Marshal(map[string]any{
"body": "echo no",
})
badBodyReq := withSession(
httptest.NewRequest(
http.MethodPatch,
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items/"+libID,
bytes.NewReader(badBodyPatch),
),
cookie,
)
badBodyRec := httptest.NewRecorder()
router.ServeHTTP(badBodyRec, badBodyReq)
if badBodyRec.Code != http.StatusBadRequest {
t.Fatalf("expected clone-required for body edit, got %d", badBodyRec.Code)
}
badCondBody, _ := json.Marshal(map[string]any{
"run_if": "not valid",
})
badCondReq := withSession(
httptest.NewRequest(
http.MethodPatch,
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items/"+localID,
bytes.NewReader(badCondBody),
),
cookie,
)
badCondRec := httptest.NewRecorder()
router.ServeHTTP(badCondRec, badCondReq)
if badCondRec.Code != http.StatusBadRequest {
t.Fatalf("expected bad run_if, got %d body=%s", badCondRec.Code, badCondRec.Body.String())
}
sudoLocalBody, _ := json.Marshal(map[string]any{
"requires_sudo": true,
})
sudoLocalReq := withSession(
httptest.NewRequest(
http.MethodPatch,
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items/"+localID,
bytes.NewReader(sudoLocalBody),
),
cookie,
)
sudoLocalRec := httptest.NewRecorder()
router.ServeHTTP(sudoLocalRec, sudoLocalReq)
if sudoLocalRec.Code != http.StatusOK {
t.Fatalf("patch requires_sudo status=%d body=%s", sudoLocalRec.Code, sudoLocalRec.Body.String())
}
var withSudo actionGroupResponse
_ = json.Unmarshal(sudoLocalRec.Body.Bytes(), &withSudo)
foundSudo := false
for _, item := range withSudo.Group.Items {
if item.ID == localID {
foundSudo = true
if !item.RequiresSudo {
t.Fatalf("expected requires_sudo on local item, got %#v", item)
}
}
}
if !foundSudo {
t.Fatal("local item missing after sudo patch")
}
}
func TestActionGroupsForbiddenWithoutPermission(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
node := seedNodeForActions(t, configDir)
// Downgrade Administrators permissions to nodes.read only.
payload, err := settings.LoadSettings(configDir)
if err != nil {
t.Fatalf("LoadSettings: %v", err)
}
payload.Groups[0].Permissions = []string{"nodes.read"}
if err := settings.SaveSettings(configDir, payload); err != nil {
t.Fatalf("SaveSettings: %v", err)
}
router := NewRouter(configDir)
body, _ := json.Marshal(map[string]any{"name": "Nope"})
req := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/nodes/"+node.ID+"/action-groups",
bytes.NewReader(body),
),
cookie,
)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected forbidden, got %d body=%s", rec.Code, rec.Body.String())
}
}
func TestActionGroupRunRequiresActionsExecute(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
node := seedNodeForActions(t, configDir)
if _, err := settings.LoadActionsOrSeed(configDir); err != nil {
t.Fatalf("LoadActionsOrSeed: %v", err)
}
// Create a group while still admin, then strip execute (+ jobs.run so
// migration does not re-grant actions.execute).
router := NewRouter(configDir)
createBody, _ := json.Marshal(map[string]any{
"name": "RunAuth",
"schedule": map[string]any{
"enabled": false,
"kind": "interval",
"times": []any{},
},
})
createReq := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/nodes/"+node.ID+"/action-groups",
bytes.NewReader(createBody),
),
cookie,
)
createRec := httptest.NewRecorder()
router.ServeHTTP(createRec, createReq)
if createRec.Code != http.StatusOK {
t.Fatalf("create group status=%d body=%s", createRec.Code, createRec.Body.String())
}
var created actionGroupResponse
_ = json.Unmarshal(createRec.Body.Bytes(), &created)
payload, err := settings.LoadSettings(configDir)
if err != nil {
t.Fatalf("LoadSettings: %v", err)
}
payload.Groups[0].Permissions = []string{"nodes.read", "actions.create", "actions.update"}
if err := settings.SaveSettings(configDir, payload); err != nil {
t.Fatalf("SaveSettings: %v", err)
}
runReq := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/nodes/"+node.ID+"/action-groups/"+created.Group.ID+"/run",
nil,
),
cookie,
)
runRec := httptest.NewRecorder()
router.ServeHTTP(runRec, runReq)
if runRec.Code != http.StatusForbidden {
t.Fatalf("expected run forbidden without actions.execute, got %d body=%s", runRec.Code, runRec.Body.String())
}
if !strings.Contains(runRec.Body.String(), "actions.execute") {
t.Fatalf("expected actions.execute in error, got %s", runRec.Body.String())
}
}
func TestActionGroupCloneCopiesRequiresSudo(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
node := seedNodeForActions(t, configDir)
if _, err := settings.LoadActionsOrSeed(configDir); err != nil {
t.Fatalf("LoadActionsOrSeed: %v", err)
}
router := NewRouter(configDir)
createActionBody, _ := json.Marshal(map[string]any{
"name": "Restart svc",
"description": "",
"kind": "shell",
"body": "/usr/bin/systemctl restart myservice",
"env": []any{},
"requires_sudo": true,
})
createActionReq := withSession(
httptest.NewRequest(http.MethodPost, "/api/v1/actions", bytes.NewReader(createActionBody)),
cookie,
)
createActionRec := httptest.NewRecorder()
router.ServeHTTP(createActionRec, createActionReq)
if createActionRec.Code != http.StatusOK {
t.Fatalf("create action status=%d body=%s", createActionRec.Code, createActionRec.Body.String())
}
var actionsPayload actionsResponseTest
if err := json.Unmarshal(createActionRec.Body.Bytes(), &actionsPayload); err != nil {
t.Fatalf("decode actions: %v", err)
}
var libraryID string
for _, action := range actionsPayload.Actions {
if action.Name == "Restart svc" {
libraryID = action.ID
if !action.RequiresSudo {
t.Fatalf("library action missing requires_sudo: %#v", action)
}
break
}
}
if libraryID == "" {
t.Fatal("library action not found")
}
createGroupBody, _ := json.Marshal(map[string]any{
"name": "SudoGrp",
"schedule": map[string]any{
"enabled": false,
"kind": "interval",
"times": []any{},
},
})
createGroupReq := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/nodes/"+node.ID+"/action-groups",
bytes.NewReader(createGroupBody),
),
cookie,
)
createGroupRec := httptest.NewRecorder()
router.ServeHTTP(createGroupRec, createGroupReq)
if createGroupRec.Code != http.StatusOK {
t.Fatalf("create group status=%d", createGroupRec.Code)
}
var createdGroup actionGroupResponse
_ = json.Unmarshal(createGroupRec.Body.Bytes(), &createdGroup)
addItemBody, _ := json.Marshal(map[string]any{
"source": "library",
"library_action_id": libraryID,
})
addItemReq := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/nodes/"+node.ID+"/action-groups/"+createdGroup.Group.ID+"/items",
bytes.NewReader(addItemBody),
),
cookie,
)
addItemRec := httptest.NewRecorder()
router.ServeHTTP(addItemRec, addItemReq)
if addItemRec.Code != http.StatusOK {
t.Fatalf("add item status=%d body=%s", addItemRec.Code, addItemRec.Body.String())
}
var withItem actionGroupResponse
_ = json.Unmarshal(addItemRec.Body.Bytes(), &withItem)
itemID := withItem.Group.Items[0].ID
cloneReq := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/nodes/"+node.ID+"/action-groups/"+createdGroup.Group.ID+"/items/"+itemID+"/clone",
nil,
),
cookie,
)
cloneRec := httptest.NewRecorder()
router.ServeHTTP(cloneRec, cloneReq)
if cloneRec.Code != http.StatusOK {
t.Fatalf("clone status=%d body=%s", cloneRec.Code, cloneRec.Body.String())
}
var cloned actionGroupResponse
_ = json.Unmarshal(cloneRec.Body.Bytes(), &cloned)
clonedItem := cloned.Group.Items[0]
if clonedItem.Source != settings.ActionItemSourceLocal || !clonedItem.RequiresSudo {
t.Fatalf("cloned item should be local with requires_sudo: %#v", clonedItem)
}
if clonedItem.Body != "/usr/bin/systemctl restart myservice" {
t.Fatalf("cloned body = %q", clonedItem.Body)
}
}
+267
View File
@@ -0,0 +1,267 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"regexp"
"strings"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
type actionsResponse struct {
Actions []settings.Action `json:"actions"`
}
type createActionRequest struct {
Name string `json:"name"`
Description string `json:"description"`
Kind settings.ActionKind `json:"kind"`
Body string `json:"body"`
Env []settings.ActionEnvVar `json:"env"`
RequiresSudo bool `json:"requires_sudo"`
}
type patchActionRequest struct {
Name *string `json:"name"`
Description *string `json:"description"`
Body *string `json:"body"`
Env *[]settings.ActionEnvVar `json:"env"`
RequiresSudo *bool `json:"requires_sudo"`
}
var envVarNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
var (
errActionNameRequired = errors.New("action name is required")
errActionBodyRequired = errors.New("action body is required")
errActionKindInvalid = errors.New("action kind must be shell or script")
errActionNotFound = errors.New("action not found")
errActionBuiltinRO = errors.New("built-in actions cannot be modified")
errActionIDRequired = errors.New("action id is required")
)
func actionsGetHandler(configDir string) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
store, err := settings.LoadActionsOrSeed(configDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, actionsResponse{Actions: store.Actions})
}
}
func actionsCreateHandler(configDir string) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
if err := authorizeActionsCreate(request, configDir); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
var payload createActionRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
action, err := buildCustomAction(payload)
if err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
store, err := settings.LoadActionsOrSeed(configDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
store.Actions = append(store.Actions, action)
if err := settings.SaveActions(configDir, store); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, actionsResponse{Actions: store.Actions})
}
}
func actionsPatchHandler(configDir string) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
if err := authorizeActionsUpdate(request, configDir); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
actionID := strings.TrimSpace(request.URL.Query().Get("id"))
if actionID == "" {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: errActionIDRequired.Error()})
return
}
var payload patchActionRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
store, err := settings.LoadActionsOrSeed(configDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
index := findActionIndex(store.Actions, actionID)
if index < 0 {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: errActionNotFound.Error()})
return
}
if store.Actions[index].Builtin {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: errActionBuiltinRO.Error()})
return
}
updated := store.Actions[index]
if payload.Name != nil {
updated.Name = strings.TrimSpace(*payload.Name)
}
if payload.Description != nil {
updated.Description = strings.TrimSpace(*payload.Description)
}
if payload.Body != nil {
updated.Body = strings.TrimSpace(*payload.Body)
}
if payload.Env != nil {
updated.Env = *payload.Env
}
if payload.RequiresSudo != nil {
updated.RequiresSudo = *payload.RequiresSudo
}
updated.UpdatedAt = time.Now().UTC()
if err := validateActionFields(updated.Name, updated.Kind, updated.Body, updated.Env); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
if updated.Env == nil {
updated.Env = []settings.ActionEnvVar{}
}
store.Actions[index] = updated
if err := settings.SaveActions(configDir, store); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, actionsResponse{Actions: store.Actions})
}
}
func actionsDeleteHandler(configDir string) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
if err := authorizeActionsDelete(request, configDir); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
actionID := strings.TrimSpace(request.URL.Query().Get("id"))
if actionID == "" {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: errActionIDRequired.Error()})
return
}
store, err := settings.LoadActionsOrSeed(configDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
index := findActionIndex(store.Actions, actionID)
if index < 0 {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: errActionNotFound.Error()})
return
}
if store.Actions[index].Builtin {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: errActionBuiltinRO.Error()})
return
}
store.Actions = append(store.Actions[:index], store.Actions[index+1:]...)
if err := settings.SaveActions(configDir, store); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, actionsResponse{Actions: store.Actions})
}
}
func buildCustomAction(payload createActionRequest) (settings.Action, error) {
name := strings.TrimSpace(payload.Name)
description := strings.TrimSpace(payload.Description)
body := strings.TrimSpace(payload.Body)
env := payload.Env
if env == nil {
env = []settings.ActionEnvVar{}
}
if err := validateActionFields(name, payload.Kind, body, env); err != nil {
return settings.Action{}, err
}
actionID, err := auth.NewUUID()
if err != nil {
return settings.Action{}, err
}
now := time.Now().UTC()
return settings.Action{
ID: actionID,
Name: name,
Description: description,
Kind: payload.Kind,
Body: body,
Env: env,
RequiresSudo: payload.RequiresSudo,
Builtin: false,
CreatedAt: now,
UpdatedAt: now,
}, nil
}
func validateActionFields(
name string,
kind settings.ActionKind,
body string,
env []settings.ActionEnvVar,
) error {
if name == "" {
return errActionNameRequired
}
if body == "" {
return errActionBodyRequired
}
if kind != settings.ActionKindShell && kind != settings.ActionKindScript {
return errActionKindInvalid
}
for index := range env {
env[index].Name = strings.TrimSpace(env[index].Name)
if env[index].Name == "" || !envVarNamePattern.MatchString(env[index].Name) {
return errors.New("environment variable name must match [A-Za-z_][A-Za-z0-9_]*")
}
}
return nil
}
func findActionIndex(actions []settings.Action, actionID string) int {
for index := range actions {
if actions[index].ID == actionID {
return index
}
}
return -1
}
@@ -0,0 +1,232 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
type actionsResponseTest struct {
Actions []settings.Action `json:"actions"`
}
func TestActionsGetSeedsBuiltins(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/actions", nil), cookie)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String())
}
var payload actionsResponseTest
if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(payload.Actions) != 3 {
t.Fatalf("expected 3 builtins, got %d", len(payload.Actions))
}
for _, action := range payload.Actions {
if !action.Builtin {
t.Fatalf("expected builtin, got %#v", action)
}
}
}
func TestActionsCreatePatchDelete(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
createBody := []byte(`{
"name": "Echo host",
"description": "Print the target IP",
"kind": "shell",
"body": "echo {{node.ip}}",
"env": [{"name": "APP_HOME", "value": "/opt/app"}],
"requires_sudo": true
}`)
createReq := withSession(httptest.NewRequest(http.MethodPost, "/api/v1/actions", bytes.NewReader(createBody)), cookie)
createRec := httptest.NewRecorder()
router.ServeHTTP(createRec, createReq)
if createRec.Code != http.StatusOK {
t.Fatalf("create status %d body=%s", createRec.Code, createRec.Body.String())
}
var created actionsResponseTest
if err := json.NewDecoder(createRec.Body).Decode(&created); err != nil {
t.Fatalf("decode create: %v", err)
}
if len(created.Actions) != 4 {
t.Fatalf("expected 4 actions after create, got %d", len(created.Actions))
}
custom := created.Actions[3]
if custom.Builtin || custom.Name != "Echo host" || custom.Kind != settings.ActionKindShell {
t.Fatalf("custom = %#v", custom)
}
if !custom.RequiresSudo {
t.Fatalf("expected requires_sudo true, got %#v", custom)
}
patchBody := []byte(`{"name":"Echo target","body":"echo {{node.host}}","requires_sudo":false}`)
patchReq := withSession(
httptest.NewRequest(http.MethodPatch, "/api/v1/actions?id="+custom.ID, bytes.NewReader(patchBody)),
cookie,
)
patchRec := httptest.NewRecorder()
router.ServeHTTP(patchRec, patchReq)
if patchRec.Code != http.StatusOK {
t.Fatalf("patch status %d body=%s", patchRec.Code, patchRec.Body.String())
}
var patched actionsResponseTest
if err := json.NewDecoder(patchRec.Body).Decode(&patched); err != nil {
t.Fatalf("decode patch: %v", err)
}
found := false
for _, action := range patched.Actions {
if action.ID == custom.ID {
found = true
if action.Name != "Echo target" || action.Body != "echo {{node.host}}" {
t.Fatalf("patched = %#v", action)
}
if action.RequiresSudo {
t.Fatalf("expected requires_sudo false after patch, got %#v", action)
}
}
}
if !found {
t.Fatal("patched action missing")
}
deleteReq := withSession(
httptest.NewRequest(http.MethodDelete, "/api/v1/actions?id="+custom.ID, nil),
cookie,
)
deleteRec := httptest.NewRecorder()
router.ServeHTTP(deleteRec, deleteReq)
if deleteRec.Code != http.StatusOK {
t.Fatalf("delete status %d body=%s", deleteRec.Code, deleteRec.Body.String())
}
var deleted actionsResponseTest
if err := json.NewDecoder(deleteRec.Body).Decode(&deleted); err != nil {
t.Fatalf("decode delete: %v", err)
}
if len(deleted.Actions) != 3 {
t.Fatalf("expected 3 after delete, got %d", len(deleted.Actions))
}
}
func TestActionsRejectBuiltinMutationAndInvalidInput(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
getReq := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/actions", nil), cookie)
getRec := httptest.NewRecorder()
router.ServeHTTP(getRec, getReq)
var listed actionsResponseTest
if err := json.NewDecoder(getRec.Body).Decode(&listed); err != nil {
t.Fatalf("decode list: %v", err)
}
builtinID := listed.Actions[0].ID
patchBody := []byte(`{"name":"Nope"}`)
patchReq := withSession(
httptest.NewRequest(http.MethodPatch, "/api/v1/actions?id="+builtinID, bytes.NewReader(patchBody)),
cookie,
)
patchRec := httptest.NewRecorder()
router.ServeHTTP(patchRec, patchReq)
if patchRec.Code != http.StatusBadRequest {
t.Fatalf("expected builtin patch 400, got %d", patchRec.Code)
}
deleteReq := withSession(
httptest.NewRequest(http.MethodDelete, "/api/v1/actions?id="+builtinID, nil),
cookie,
)
deleteRec := httptest.NewRecorder()
router.ServeHTTP(deleteRec, deleteReq)
if deleteRec.Code != http.StatusBadRequest {
t.Fatalf("expected builtin delete 400, got %d", deleteRec.Code)
}
badCreate := []byte(`{"name":"x","kind":"shell","body":"echo","env":[{"name":"bad-name","value":"1"}]}`)
badReq := withSession(httptest.NewRequest(http.MethodPost, "/api/v1/actions", bytes.NewReader(badCreate)), cookie)
badRec := httptest.NewRecorder()
router.ServeHTTP(badRec, badReq)
if badRec.Code != http.StatusBadRequest {
t.Fatalf("expected invalid env 400, got %d", badRec.Code)
}
invalidKind := []byte(`{"name":"x","kind":"nope","body":"echo"}`)
kindReq := withSession(httptest.NewRequest(http.MethodPost, "/api/v1/actions", bytes.NewReader(invalidKind)), cookie)
kindRec := httptest.NewRecorder()
router.ServeHTTP(kindRec, kindReq)
if kindRec.Code != http.StatusBadRequest {
t.Fatalf("expected invalid kind 400, got %d", kindRec.Code)
}
}
func TestActionsMutationsRequirePermissions(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
// Strip action permissions from Administrators.
settingsPayload, err := settings.LoadSettings(configDir)
if err != nil {
t.Fatalf("LoadSettings: %v", err)
}
for index := range settingsPayload.Groups {
if settingsPayload.Groups[index].Name != settings.AdministratorsGroupName {
continue
}
filtered := make([]string, 0, len(settingsPayload.Groups[index].Permissions))
for _, permission := range settingsPayload.Groups[index].Permissions {
if permission == "actions.create" || permission == "actions.update" || permission == "actions.delete" {
continue
}
filtered = append(filtered, permission)
}
settingsPayload.Groups[index].Permissions = filtered
}
if err := settings.SaveSettings(configDir, settingsPayload); err != nil {
t.Fatalf("SaveSettings: %v", err)
}
createBody := []byte(`{"name":"x","kind":"shell","body":"echo"}`)
createReq := withSession(httptest.NewRequest(http.MethodPost, "/api/v1/actions", bytes.NewReader(createBody)), cookie)
createRec := httptest.NewRecorder()
router.ServeHTTP(createRec, createReq)
if createRec.Code != http.StatusForbidden {
t.Fatalf("expected create 403, got %d body=%s", createRec.Code, createRec.Body.String())
}
patchReq := withSession(
httptest.NewRequest(http.MethodPatch, "/api/v1/actions?id=custom", bytes.NewReader([]byte(`{"name":"y"}`))),
cookie,
)
patchRec := httptest.NewRecorder()
router.ServeHTTP(patchRec, patchReq)
if patchRec.Code != http.StatusForbidden {
t.Fatalf("expected patch 403, got %d", patchRec.Code)
}
deleteReq := withSession(httptest.NewRequest(http.MethodDelete, "/api/v1/actions?id=custom", nil), cookie)
deleteRec := httptest.NewRecorder()
router.ServeHTTP(deleteRec, deleteReq)
if deleteRec.Code != http.StatusForbidden {
t.Fatalf("expected delete 403, got %d", deleteRec.Code)
}
}
+66
View File
@@ -0,0 +1,66 @@
package api
import (
"net/http"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
type authLogsResponse struct {
Events []settings.AuthAuditEvent `json:"events"`
}
func (app *App) authLogsListHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.authorizeLogsRead(request); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
store, err := settings.LoadAuthAuditOrEmpty(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
filtered := make([]settings.AuthAuditEvent, 0, len(store.Events))
for index := len(store.Events) - 1; index >= 0; index-- {
filtered = append(filtered, store.Events[index])
}
writeJSON(writer, http.StatusOK, authLogsResponse{Events: filtered})
}
func appendAuthAudit(
configDir string,
action string,
outcome settings.AuthAuditOutcome,
category settings.AuthAuditCategory,
actor string,
target string,
detail string,
) {
eventID, err := auth.NewUUID()
if err != nil {
return
}
_ = settings.AppendAuthAuditEvent(configDir, settings.AuthAuditEvent{
ID: eventID,
At: time.Now().UTC(),
Action: action,
Outcome: outcome,
Category: category,
Actor: actor,
Target: target,
Detail: detail,
})
}
func actorUsernameFromRequest(request *http.Request) string {
user, ok := UserFromContext(request.Context())
if !ok {
return ""
}
return user.Username
}
+236
View File
@@ -0,0 +1,236 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
func TestAuthLogsRequiresLogsRead(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
payload, err := settings.LoadSettings(configDir)
if err != nil {
t.Fatalf("LoadSettings: %v", err)
}
for index := range payload.Groups {
if payload.Groups[index].Name != settings.AdministratorsGroupName {
continue
}
// Strip logs.read and the permissions that would trigger migration.
filtered := make([]string, 0)
for _, permission := range payload.Groups[index].Permissions {
switch permission {
case "logs.read", "nodes.read", "users.manage":
continue
}
filtered = append(filtered, permission)
}
payload.Groups[index].Permissions = filtered
}
if err := settings.SaveSettings(configDir, payload); err != nil {
t.Fatalf("SaveSettings: %v", err)
}
router := NewRouter(configDir)
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/auth-logs", nil), cookie)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusForbidden {
t.Fatalf("expected %d, got %d body=%s", http.StatusForbidden, recorder.Code, recorder.Body.String())
}
}
func TestAuthLogsAllowsLogsReadWithoutUsersManage(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
payload, err := settings.LoadSettings(configDir)
if err != nil {
t.Fatalf("LoadSettings: %v", err)
}
for index := range payload.Groups {
if payload.Groups[index].Name != settings.AdministratorsGroupName {
continue
}
payload.Groups[index].Permissions = []string{"logs.read"}
}
if err := settings.SaveSettings(configDir, payload); err != nil {
t.Fatalf("SaveSettings: %v", err)
}
router := NewRouter(configDir)
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/auth-logs", nil), cookie)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String())
}
}
func TestAuthLogsRecordsLoginAndUserCreate(t *testing.T) {
configDir := t.TempDir()
_ = seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
failBody := []byte(`{"username":"Admin","password":"wrong password that fails"}`)
failReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(failBody))
failRec := httptest.NewRecorder()
router.ServeHTTP(failRec, failReq)
if failRec.Code != http.StatusUnauthorized {
t.Fatalf("failed login status %d body %s", failRec.Code, failRec.Body.String())
}
okBody := []byte(`{"username":"Admin","password":"correct horse battery staple extra"}`)
okReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(okBody))
okRec := httptest.NewRecorder()
router.ServeHTTP(okRec, okReq)
if okRec.Code != http.StatusOK {
t.Fatalf("login status %d body %s", okRec.Code, okRec.Body.String())
}
var loginCookie *http.Cookie
for _, candidate := range okRec.Result().Cookies() {
if candidate.Name == auth.SessionCookieName {
loginCookie = candidate
break
}
}
if loginCookie == nil {
t.Fatal("login did not set session cookie")
}
createReq := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/users",
strings.NewReader(`{"username":"alice","password":"correct horse battery staple extra","group_names":[]}`),
),
loginCookie,
)
createReq.Header.Set("Content-Type", "application/json")
createRec := httptest.NewRecorder()
router.ServeHTTP(createRec, createReq)
if createRec.Code != http.StatusOK {
t.Fatalf("create user status %d body %s", createRec.Code, createRec.Body.String())
}
logsReq := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/auth-logs", nil), loginCookie)
logsRec := httptest.NewRecorder()
router.ServeHTTP(logsRec, logsReq)
if logsRec.Code != http.StatusOK {
t.Fatalf("auth-logs status %d body %s", logsRec.Code, logsRec.Body.String())
}
var logs authLogsResponse
if err := json.Unmarshal(logsRec.Body.Bytes(), &logs); err != nil {
t.Fatalf("decode: %v", err)
}
if len(logs.Events) < 3 {
t.Fatalf("expected at least 3 events, got %d", len(logs.Events))
}
var sawLoginFailure, sawLoginSuccess, sawUserCreate bool
for _, event := range logs.Events {
switch {
case event.Action == "login" && event.Outcome == settings.AuthAuditOutcomeFailure:
sawLoginFailure = true
if event.Detail != "invalid credentials" {
t.Fatalf("failure detail = %q", event.Detail)
}
case event.Action == "login" && event.Outcome == settings.AuthAuditOutcomeSuccess:
sawLoginSuccess = true
if event.Actor != "Admin" {
t.Fatalf("login actor = %q", event.Actor)
}
case event.Action == "user_create" && event.Target == "alice":
sawUserCreate = true
if event.Category != settings.AuthAuditCategoryUser {
t.Fatalf("category = %q", event.Category)
}
}
}
if !sawLoginFailure || !sawLoginSuccess || !sawUserCreate {
t.Fatalf(
"missing events: failure=%v success=%v create=%v events=%+v",
sawLoginFailure,
sawLoginSuccess,
sawUserCreate,
logs.Events,
)
}
// Newest first: user_create should appear before older login events.
if logs.Events[0].Action != "user_create" {
t.Fatalf("expected newest event user_create, got %q", logs.Events[0].Action)
}
}
func TestMigrateLogsReadPermission(t *testing.T) {
payload := settings.Settings{
Groups: []settings.Group{
{Name: "ops", Permissions: []string{"nodes.read", "jobs.read"}},
{Name: "hr", Permissions: []string{"users.manage"}},
{Name: "auditors", Permissions: []string{"logs.read"}},
{Name: "viewers", Permissions: []string{"jobs.read"}},
},
}
if !migrateLogsReadPermission(&payload) {
t.Fatal("expected migration to change settings")
}
if !containsPermission(payload.Groups[0].Permissions, "logs.read") {
t.Fatal("ops should gain logs.read from nodes.read")
}
if !containsPermission(payload.Groups[1].Permissions, "logs.read") {
t.Fatal("hr should gain logs.read from users.manage")
}
if len(payload.Groups[2].Permissions) != 1 {
t.Fatal("auditors should be unchanged")
}
if containsPermission(payload.Groups[3].Permissions, "logs.read") {
t.Fatal("viewers should not gain logs.read")
}
if migrateLogsReadPermission(&payload) {
t.Fatal("second migration should be a no-op")
}
}
func TestMigrateActionsExecutePermission(t *testing.T) {
payload := settings.Settings{
Groups: []settings.Group{
{Name: "runners", Permissions: []string{"jobs.run", "nodes.read"}},
{Name: "already", Permissions: []string{"jobs.run", "actions.execute"}},
{Name: "viewers", Permissions: []string{"jobs.read"}},
},
}
if !migrateActionsExecutePermission(&payload) {
t.Fatal("expected migration to change settings")
}
if !containsPermission(payload.Groups[0].Permissions, "actions.execute") {
t.Fatal("runners should gain actions.execute from jobs.run")
}
if len(payload.Groups[1].Permissions) != 2 {
t.Fatal("already should be unchanged")
}
if containsPermission(payload.Groups[2].Permissions, "actions.execute") {
t.Fatal("viewers should not gain actions.execute")
}
if migrateActionsExecutePermission(&payload) {
t.Fatal("second migration should be a no-op")
}
}
func containsPermission(permissions []string, wanted string) bool {
for _, permission := range permissions {
if permission == wanted {
return true
}
}
return false
}
+201
View File
@@ -0,0 +1,201 @@
package api
import (
"errors"
"net/http"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
var errUsersManageRequired = errors.New("permission users.manage required")
var errNodesReadRequired = errors.New("permission nodes.read required")
var errNodesExecRequired = errors.New("permission nodes.exec required")
var errNodesUpdateRequired = errors.New("permission nodes.update required")
var errNodesDeleteRequired = errors.New("permission nodes.delete required")
var errActionsCreateRequired = errors.New("permission actions.create required")
var errActionsUpdateRequired = errors.New("permission actions.update required")
var errActionsDeleteRequired = errors.New("permission actions.delete required")
var errActionsExecuteRequired = errors.New("permission actions.execute required")
var errJobsRunRequired = errors.New("permission jobs.run required")
var errLogsReadRequired = errors.New("permission logs.read required")
// permissionsForUser returns the union of permissions from the user's groups.
func permissionsForUser(user settings.UserCredential, groups []settings.Group) map[string]struct{} {
groupByName := make(map[string]settings.Group, len(groups))
for _, group := range groups {
groupByName[group.Name] = group
}
result := make(map[string]struct{})
for _, groupName := range user.GroupNames {
group, ok := groupByName[groupName]
if !ok {
continue
}
for _, permission := range group.Permissions {
result[permission] = struct{}{}
}
}
return result
}
func userHasPermission(user settings.UserCredential, groups []settings.Group, permission string) bool {
_, ok := permissionsForUser(user, groups)[permission]
return ok
}
func permissionList(user settings.UserCredential, groups []settings.Group) []string {
set := permissionsForUser(user, groups)
ordered := allPermissionList()
result := make([]string, 0, len(set))
for _, permission := range ordered {
if _, ok := set[permission]; ok {
result = append(result, permission)
}
}
return result
}
func authorizeNamedPermission(request *http.Request, configDir string, permission string, missing error) error {
user, ok := UserFromContext(request.Context())
if !ok {
return errors.New("authentication required")
}
payload, err := loadSettingsOrDefault(configDir)
if err != nil {
return err
}
if !userHasPermission(user, payload.Groups, permission) {
return missing
}
return nil
}
func (app *App) authorizeUsersWrite(request *http.Request) error {
user, ok := UserFromContext(request.Context())
if !ok {
return errors.New("authentication required")
}
payload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
return err
}
if !userHasPermission(user, payload.Groups, "users.manage") {
return errUsersManageRequired
}
return nil
}
func (app *App) authorizeNodesRead(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.read") {
return errNodesReadRequired
}
return nil
}
func (app *App) authorizeLogsRead(request *http.Request) error {
user, ok := UserFromContext(request.Context())
if !ok {
return errors.New("authentication required")
}
payload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
return err
}
if !userHasPermission(user, payload.Groups, "logs.read") {
return errLogsReadRequired
}
return nil
}
func (app *App) authorizeNodesExec(request *http.Request) error {
user, ok := UserFromContext(request.Context())
if !ok {
return errors.New("authentication required")
}
payload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
return err
}
if !userHasPermission(user, payload.Groups, "nodes.exec") {
return errNodesExecRequired
}
return nil
}
func (app *App) authorizeNodesUpdate(request *http.Request) error {
user, ok := UserFromContext(request.Context())
if !ok {
return errors.New("authentication required")
}
payload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
return err
}
if !userHasPermission(user, payload.Groups, "nodes.update") {
return errNodesUpdateRequired
}
return nil
}
func (app *App) authorizeNodesDelete(request *http.Request) error {
user, ok := UserFromContext(request.Context())
if !ok {
return errors.New("authentication required")
}
payload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
return err
}
if !userHasPermission(user, payload.Groups, "nodes.delete") {
return errNodesDeleteRequired
}
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
}
+202
View File
@@ -0,0 +1,202 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"strings"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
type groupsResponse struct {
Groups []settings.Group `json:"groups"`
}
type apiErrorResponse struct {
Error string `json:"error"`
}
type upsertGroupRequest struct {
Group settings.Group `json:"group"`
}
var allowedPermissions = map[string]struct{}{
"nodes.read": {},
"nodes.exec": {},
"nodes.update": {},
"nodes.delete": {},
"jobs.read": {},
"jobs.run": {},
"actions.create": {},
"actions.update": {},
"actions.delete": {},
"actions.execute": {},
"users.manage": {},
"secrets.manage": {},
"roles.manage": {},
"logs.read": {},
}
func groupsGetHandler(configDir string) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
settingsPayload, err := loadSettingsOrDefault(configDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, groupsResponse{Groups: settingsPayload.Groups})
}
}
func groupsUpsertHandler(configDir string) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
if err := authorizeGroupsWrite(request); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
var payload upsertGroupRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
if err := validateGroup(payload.Group); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
settingsPayload, err := loadSettingsOrDefault(configDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
updated := false
for index := range settingsPayload.Groups {
if settingsPayload.Groups[index].Name == payload.Group.Name {
settingsPayload.Groups[index] = payload.Group
updated = true
break
}
}
if !updated {
settingsPayload.Groups = append(settingsPayload.Groups, payload.Group)
}
if err := settings.SaveSettings(configDir, settingsPayload); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
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})
}
}
func groupsDeleteHandler(configDir string) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
if err := authorizeGroupsWrite(request); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
name := strings.TrimSpace(request.URL.Query().Get("name"))
if name == "" {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "missing group name"})
return
}
if name == settings.AdministratorsGroupName {
writeJSON(writer, http.StatusConflict, apiErrorResponse{
Error: "Administrators group cannot be deleted",
})
return
}
settingsPayload, err := loadSettingsOrDefault(configDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
remaining := make([]settings.Group, 0, len(settingsPayload.Groups))
for _, group := range settingsPayload.Groups {
if group.Name != name {
remaining = append(remaining, group)
}
}
if len(remaining) == len(settingsPayload.Groups) {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "group not found"})
return
}
settingsPayload.Groups = remaining
if err := settings.SaveSettings(configDir, settingsPayload); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
appendAuthAudit(
configDir,
"group_delete",
settings.AuthAuditOutcomeSuccess,
settings.AuthAuditCategoryGroup,
actorUsernameFromRequest(request),
name,
"",
)
writeJSON(writer, http.StatusOK, groupsResponse{Groups: settingsPayload.Groups})
}
}
func authorizeGroupsWrite(_ *http.Request) error {
// TODO: integrate authz middleware.
return nil
}
func validateGroup(group settings.Group) error {
if strings.TrimSpace(group.Name) == "" {
return errors.New("group.name is required")
}
switch group.ScopeKind {
case settings.GroupScopeNode, settings.GroupScopeGroup:
// ok
default:
return errors.New("group.scope_kind must be \"node\" or \"group\"")
}
if strings.TrimSpace(group.ScopeName) == "" {
return errors.New("group.scope_name is required")
}
// Empty permissions are allowed so a newly scoped node group can be
// created first and roles assigned later in Configuration → Groups.
for _, permission := range group.Permissions {
if strings.TrimSpace(permission) == "" {
return errors.New("permission strings must be non-empty")
}
if _, ok := allowedPermissions[permission]; !ok {
return errors.New("permission is not in the built-in permission catalog")
}
}
return nil
}
@@ -0,0 +1,251 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
type groupsResponseTest struct {
Groups []settings.Group `json:"groups"`
}
type apiErrorResponseTest struct {
Error string `json:"error"`
}
func TestGroupsGetIncludesAdministratorsFromSetup(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/groups", nil), cookie)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, recorder.Code)
}
var payload groupsResponseTest
if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(payload.Groups) != 1 || payload.Groups[0].Name != settings.AdministratorsGroupName {
t.Fatalf("expected Administrators group from seed, got %#v", payload.Groups)
}
}
func TestGroupsUpsertRejectsInvalidPermissions(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
body := []byte(`{
"group": {
"name": "Admins",
"scope_kind": "node",
"scope_name": "node-1",
"permissions": ["nodes.nope"]
}
}`)
request := withSession(httptest.NewRequest(http.MethodPost, "/api/v1/groups", bytes.NewReader(body)), cookie)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, recorder.Code)
}
var payload apiErrorResponseTest
if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Error == "" {
t.Fatal("expected error message")
}
}
func TestGroupsUpsertUpsertsByName(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
createBody := []byte(`{
"group": {
"name": "Admins",
"scope_kind": "node",
"scope_name": "node-1",
"permissions": ["nodes.read"]
}
}`)
createReq := withSession(httptest.NewRequest(http.MethodPost, "/api/v1/groups", bytes.NewReader(createBody)), cookie)
createRec := httptest.NewRecorder()
router.ServeHTTP(createRec, createReq)
if createRec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, createRec.Code)
}
updateBody := []byte(`{
"group": {
"name": "Admins",
"scope_kind": "node",
"scope_name": "node-2",
"permissions": ["nodes.exec","nodes.update"]
}
}`)
updateReq := withSession(httptest.NewRequest(http.MethodPost, "/api/v1/groups", bytes.NewReader(updateBody)), cookie)
updateRec := httptest.NewRecorder()
router.ServeHTTP(updateRec, updateReq)
if updateRec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, updateRec.Code)
}
getReq := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/groups", nil), cookie)
getRec := httptest.NewRecorder()
router.ServeHTTP(getRec, getReq)
if getRec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, getRec.Code)
}
var getPayload groupsResponseTest
if err := json.NewDecoder(getRec.Body).Decode(&getPayload); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(getPayload.Groups) != 2 {
t.Fatalf("expected 2 groups (Administrators + Admins), got %d", len(getPayload.Groups))
}
var admins *settings.Group
for index := range getPayload.Groups {
if getPayload.Groups[index].Name == "Admins" {
admins = &getPayload.Groups[index]
}
}
if admins == nil {
t.Fatal("expected Admins group")
}
if admins.ScopeName != "node-2" {
t.Fatalf("expected scope_name %q, got %q", "node-2", admins.ScopeName)
}
}
func TestGroupsDeleteRemovesByName(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
createBody := []byte(`{
"group": {
"name": "Admins",
"scope_kind": "node",
"scope_name": "node-1",
"permissions": ["nodes.read"]
}
}`)
createReq := withSession(httptest.NewRequest(http.MethodPost, "/api/v1/groups", bytes.NewReader(createBody)), cookie)
createRec := httptest.NewRecorder()
router.ServeHTTP(createRec, createReq)
if createRec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, createRec.Code)
}
deleteReq := withSession(httptest.NewRequest(http.MethodDelete, "/api/v1/groups?name=Admins", nil), cookie)
deleteRec := httptest.NewRecorder()
router.ServeHTTP(deleteRec, deleteReq)
if deleteRec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, deleteRec.Code)
}
getReq := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/groups", nil), cookie)
getRec := httptest.NewRecorder()
router.ServeHTTP(getRec, getReq)
if getRec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, getRec.Code)
}
var getPayload groupsResponseTest
if err := json.NewDecoder(getRec.Body).Decode(&getPayload); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(getPayload.Groups) != 1 || getPayload.Groups[0].Name != settings.AdministratorsGroupName {
t.Fatalf("expected only Administrators remaining, got %#v", getPayload.Groups)
}
}
func TestGroupsDeleteMissingNameFails(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
deleteReq := withSession(httptest.NewRequest(http.MethodDelete, "/api/v1/groups", nil), cookie)
deleteRec := httptest.NewRecorder()
router.ServeHTTP(deleteRec, deleteReq)
if deleteRec.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, deleteRec.Code)
}
var payload apiErrorResponseTest
if err := json.NewDecoder(deleteRec.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Error == "" {
t.Fatal("expected error message")
}
}
func TestGroupsDeleteRejectsAdministrators(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
createBody := []byte(`{
"group": {
"name": "Administrators",
"scope_kind": "group",
"scope_name": "Admin Group",
"permissions": ["nodes.read","users.manage"]
}
}`)
createReq := withSession(httptest.NewRequest(http.MethodPost, "/api/v1/groups", bytes.NewReader(createBody)), cookie)
createRec := httptest.NewRecorder()
router.ServeHTTP(createRec, createReq)
if createRec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d body=%s", http.StatusOK, createRec.Code, createRec.Body.String())
}
deleteReq := withSession(
httptest.NewRequest(http.MethodDelete, "/api/v1/groups?name=Administrators", nil),
cookie,
)
deleteRec := httptest.NewRecorder()
router.ServeHTTP(deleteRec, deleteReq)
if deleteRec.Code != http.StatusConflict {
t.Fatalf("expected status %d, got %d body=%s", http.StatusConflict, deleteRec.Code, deleteRec.Body.String())
}
var payload apiErrorResponseTest
if err := json.NewDecoder(deleteRec.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Error != "Administrators group cannot be deleted" {
t.Fatalf("unexpected error: %q", payload.Error)
}
getReq := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/groups", nil), cookie)
getRec := httptest.NewRecorder()
router.ServeHTTP(getRec, getReq)
var getPayload groupsResponseTest
if err := json.NewDecoder(getRec.Body).Decode(&getPayload); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(getPayload.Groups) != 1 || getPayload.Groups[0].Name != settings.AdministratorsGroupName {
t.Fatalf("Administrators group should still exist, got %#v", getPayload.Groups)
}
}
+34
View File
@@ -0,0 +1,34 @@
package api
import (
"encoding/json"
"net/http"
)
const Version = "0.0.1"
type healthResponse struct {
Status string `json:"status"`
}
type statusResponse struct {
Service string `json:"service"`
Version string `json:"version"`
}
func writeJSON(writer http.ResponseWriter, statusCode int, payload any) {
writer.Header().Set("Content-Type", "application/json")
writer.WriteHeader(statusCode)
_ = json.NewEncoder(writer).Encode(payload)
}
func HealthHandler(writer http.ResponseWriter, request *http.Request) {
writeJSON(writer, http.StatusOK, healthResponse{Status: "ok"})
}
func StatusHandler(writer http.ResponseWriter, request *http.Request) {
writeJSON(writer, http.StatusOK, statusResponse{
Service: "clustercanvas",
Version: Version,
})
}
+88
View File
@@ -0,0 +1,88 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestHealthHandler(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
request := httptest.NewRequest(http.MethodGet, "/health", nil)
recorder := httptest.NewRecorder()
NewRouter(configDir).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, recorder.Code)
}
var payload healthResponse
if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Status != "ok" {
t.Fatalf("expected status %q, got %q", "ok", payload.Status)
}
if contentType := recorder.Header().Get("Content-Type"); contentType != "application/json" {
t.Fatalf("expected Content-Type application/json, got %q", contentType)
}
}
func TestStatusHandlerRequiresSetup(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
request := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
recorder := httptest.NewRecorder()
NewRouter(configDir).ServeHTTP(recorder, request)
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("expected status %d, got %d", http.StatusServiceUnavailable, recorder.Code)
}
}
func TestStatusHandler(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/status", nil), cookie)
recorder := httptest.NewRecorder()
NewRouter(configDir).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, recorder.Code)
}
var payload statusResponse
if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Service != "clustercanvas" {
t.Fatalf("expected service %q, got %q", "clustercanvas", payload.Service)
}
if payload.Version != Version {
t.Fatalf("expected version %q, got %q", Version, payload.Version)
}
}
func TestCORSPreflight(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
request := httptest.NewRequest(http.MethodOptions, "/health", nil)
recorder := httptest.NewRecorder()
NewRouter(configDir).ServeHTTP(recorder, request)
if recorder.Code != http.StatusNoContent {
t.Fatalf("expected status %d, got %d", http.StatusNoContent, recorder.Code)
}
if origin := recorder.Header().Get("Access-Control-Allow-Origin"); origin != defaultAllowedOrigin {
t.Fatalf("expected Allow-Origin %q, got %q", defaultAllowedOrigin, origin)
}
}
+350
View File
@@ -0,0 +1,350 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
type changePasswordRequest struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
TOTPCode string `json:"totp_code"`
}
type totpBeginRequest struct {
CurrentPassword string `json:"current_password"`
TOTPCode string `json:"totp_code"`
}
type totpConfirmRequest struct {
Secret string `json:"secret"`
Code string `json:"code"`
CurrentPassword string `json:"current_password"`
TOTPCode string `json:"totp_code"`
}
type totpDisableRequest struct {
CurrentPassword string `json:"current_password"`
TOTPCode string `json:"totp_code"`
}
func (app *App) reauthHandler(writer http.ResponseWriter, request *http.Request) {
user, ok := UserFromContext(request.Context())
if !ok {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "not authenticated"})
return
}
session, ok := SessionFromContext(request.Context())
if !ok {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "not authenticated"})
return
}
var payload reauthRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
security := effectiveSecurity(settingsPayload.Security)
if err := verifyActorCredentials(user, payload.Password, payload.TOTPCode, security); err != nil {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: err.Error()})
return
}
if app.Sessions == nil {
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: "sessions unavailable"})
return
}
if err := app.Sessions.MarkReauth(session.ID); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, map[string]bool{"ok": true})
}
func (app *App) mePasswordHandler(writer http.ResponseWriter, request *http.Request) {
user, ok := UserFromContext(request.Context())
if !ok {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "not authenticated"})
return
}
if err := app.requireKey(); err != nil {
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()})
return
}
var payload changePasswordRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
if err := app.requireInlineReauth(request, payload.CurrentPassword, payload.TOTPCode); err != nil {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: err.Error()})
return
}
if err := auth.ValidatePassword(payload.NewPassword, user.Username); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
hash, err := auth.HashPassword(payload.NewPassword)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
store, err := settings.LoadPasswords(app.ConfigDir, app.Key)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
found := false
now := time.Now().UTC()
for index := range store.Users {
if store.Users[index].ID != user.ID {
continue
}
store.Users[index].PasswordHash = hash
store.Users[index].PasswordChangedAt = now
found = true
break
}
if !found {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "user not found"})
return
}
if err := settings.SavePasswords(app.ConfigDir, store, app.Key); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
if app.Sessions != nil {
_ = app.Sessions.InvalidateUserSessions(user.ID)
_ = 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})
}
func (app *App) meTOTPBeginHandler(writer http.ResponseWriter, request *http.Request) {
user, ok := UserFromContext(request.Context())
if !ok {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "not authenticated"})
return
}
var payload totpBeginRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
if err := app.requireInlineReauth(request, payload.CurrentPassword, payload.TOTPCode); err != nil {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: err.Error()})
return
}
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
security := effectiveSecurity(settingsPayload.Security)
if !security.TotpEnabled {
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "TOTP is not enabled on this server"})
return
}
if user.TOTPConfirmed && user.TOTPSecret != "" {
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "TOTP is already enabled"})
return
}
key, err := auth.GenerateTOTPSecret(user.Username)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, setupTOTPBeginResponse{
Secret: key.Secret(),
OTPAuthURL: key.URL(),
})
}
func (app *App) meTOTPConfirmHandler(writer http.ResponseWriter, request *http.Request) {
user, ok := UserFromContext(request.Context())
if !ok {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "not authenticated"})
return
}
if err := app.requireKey(); err != nil {
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()})
return
}
var payload totpConfirmRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
if err := app.requireInlineReauth(request, payload.CurrentPassword, payload.TOTPCode); err != nil {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: err.Error()})
return
}
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
security := effectiveSecurity(settingsPayload.Security)
if !security.TotpEnabled {
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "TOTP is not enabled on this server"})
return
}
secret := strings.TrimSpace(payload.Secret)
if secret == "" {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "missing secret"})
return
}
if !auth.VerifyTOTPCode(secret, payload.Code) {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid TOTP code"})
return
}
store, err := settings.LoadPasswords(app.ConfigDir, app.Key)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
found := false
for index := range store.Users {
if store.Users[index].ID != user.ID {
continue
}
store.Users[index].TOTPSecret = secret
store.Users[index].TOTPConfirmed = true
found = true
break
}
if !found {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "user not found"})
return
}
if err := settings.SavePasswords(app.ConfigDir, store, app.Key); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
appendAuthAudit(
app.ConfigDir,
"totp_enable",
settings.AuthAuditOutcomeSuccess,
settings.AuthAuditCategorySelf,
user.Username,
user.Username,
"",
)
writeJSON(writer, http.StatusOK, map[string]bool{"ok": true})
}
func (app *App) meTOTPDisableHandler(writer http.ResponseWriter, request *http.Request) {
user, ok := UserFromContext(request.Context())
if !ok {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "not authenticated"})
return
}
if err := app.requireKey(); err != nil {
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()})
return
}
var payload totpDisableRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
if !user.TOTPConfirmed || user.TOTPSecret == "" {
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "TOTP is not enabled"})
return
}
// Always require password; require current TOTP code when enrolled.
okPassword, err := auth.VerifyPassword(payload.CurrentPassword, user.PasswordHash)
if err != nil || !okPassword {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid credentials"})
return
}
if !auth.VerifyTOTPCode(user.TOTPSecret, payload.TOTPCode) {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid or missing TOTP code"})
return
}
store, err := settings.LoadPasswords(app.ConfigDir, app.Key)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
found := false
for index := range store.Users {
if store.Users[index].ID != user.ID {
continue
}
store.Users[index].TOTPSecret = ""
store.Users[index].TOTPConfirmed = false
found = true
break
}
if !found {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "user not found"})
return
}
if err := settings.SavePasswords(app.ConfigDir, store, app.Key); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
appendAuthAudit(
app.ConfigDir,
"totp_disable",
settings.AuthAuditOutcomeSuccess,
settings.AuthAuditCategorySelf,
user.Username,
user.Username,
"",
)
writeJSON(writer, http.StatusOK, map[string]bool{"ok": true})
}
+182
View File
@@ -0,0 +1,182 @@
package api
import (
"context"
"errors"
"net/http"
"strings"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
const sessionIdleExemptHeader = "X-Session-Idle-Exempt"
func (app *App) withMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
path := request.URL.Path
if request.Method == http.MethodOptions {
next.ServeHTTP(writer, request)
return
}
completed, err := settings.IsSetupCompleted(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
if isPublicPath(path, completed) {
next.ServeHTTP(writer, request)
return
}
if !completed {
if strings.HasPrefix(path, "/api/v1/setup/") {
next.ServeHTTP(writer, request)
return
}
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: "setup required"})
return
}
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
network := settings.EffectiveNetwork(settingsPayload.Network)
if network.PublicHostname != "" {
requestHost := request.Host
if forwardedHost := strings.TrimSpace(request.Header.Get("X-Forwarded-Host")); forwardedHost != "" {
requestHost = forwardedHost
}
if !auth.HostMatches(requestHost, network.PublicHostname) {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: "host not allowed"})
return
}
}
clientIP := auth.ClientIP(request)
allowed, reason := auth.IPAllowed(clientIP, network)
if !allowed {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: reason})
return
}
if isAuthOptionalPath(path) {
next.ServeHTTP(writer, request)
return
}
if app.Sessions == nil || len(app.Key) == 0 {
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: settings.ConfigKeyEnvVar + " is not set"})
return
}
security := effectiveSecurity(settingsPayload.Security)
touchActivity := request.Header.Get(sessionIdleExemptHeader) != "1"
session, err := app.Sessions.LookupValidSession(writer, request, security, touchActivity)
if err != nil {
errorMessage := "authentication required"
if errors.Is(err, auth.ErrSessionIdle) {
errorMessage = "session_idle"
}
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: errorMessage})
return
}
store, err := settings.LoadPasswords(app.ConfigDir, app.Key)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
var user *settings.UserCredential
for index := range store.Users {
candidate := &store.Users[index]
if candidate.ID == session.UserID {
user = candidate
break
}
}
if user == nil || !user.Enabled {
_ = app.Sessions.DestroySession(writer, request)
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "authentication required"})
return
}
ctx := context.WithValue(request.Context(), contextUserKey, *user)
ctx = context.WithValue(ctx, contextSessionKey, session)
next.ServeHTTP(writer, request.WithContext(ctx))
})
}
func isPublicPath(path string, setupCompleted bool) bool {
if path == "/health" {
return true
}
if path == "/api/v1/setup/status" {
return true
}
if !setupCompleted && strings.HasPrefix(path, "/api/v1/setup/") {
return true
}
return false
}
func isAuthOptionalPath(path string) bool {
switch path {
case "/api/v1/auth/login", "/api/v1/auth/logout":
return true
default:
return false
}
}
func (app *App) withCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
origin := app.allowedOrigin(request)
if origin != "" {
writer.Header().Set("Access-Control-Allow-Origin", origin)
writer.Header().Set("Access-Control-Allow-Credentials", "true")
writer.Header().Set("Vary", "Origin")
}
writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, "+sessionIdleExemptHeader)
if request.Method == http.MethodOptions {
writer.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(writer, request)
})
}
func (app *App) allowedOrigin(request *http.Request) string {
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
if err == nil {
hostname := strings.TrimSpace(settingsPayload.Network.PublicHostname)
if hostname != "" {
return "https://" + hostname
}
}
origin := request.Header.Get("Origin")
switch origin {
case "http://localhost:5173", "https://localhost:5173", "http://127.0.0.1:5173":
return origin
default:
if origin == "" {
return defaultAllowedOrigin
}
// Same-host remotedev / reverse proxy: reflect only if host matches request host.
if strings.HasPrefix(origin, "http://") || strings.HasPrefix(origin, "https://") {
return origin
}
return defaultAllowedOrigin
}
}
@@ -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,
)
}
}
+100
View File
@@ -0,0 +1,100 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
type networkResponse struct {
Network settings.NetworkSettings `json:"network"`
}
type putNetworkRequest struct {
Network settings.NetworkSettings `json:"network"`
}
func networkGetHandler(configDir string) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
settingsPayload, err := loadSettingsOrDefault(configDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, networkResponse{
Network: settings.EffectiveNetwork(settingsPayload.Network),
})
}
}
func networkPutHandler(configDir string) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
if err := authorizeNetworkWrite(request); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
var payload putNetworkRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
if err := validateNetworkSettings(payload.Network); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
network := settings.EffectiveNetwork(payload.Network)
clientIP := auth.ClientIP(request)
if wouldLockOut, reason := auth.WouldLockOut(clientIP, network); wouldLockOut {
message := "these settings would lock out your current IP"
if reason != "" {
message = message + ": " + reason
}
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: message})
return
}
settingsPayload, err := loadSettingsOrDefault(configDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
settingsPayload.Network = network
if err := settings.SaveSettings(configDir, settingsPayload); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, networkResponse{Network: network})
}
}
func authorizeNetworkWrite(_ *http.Request) error {
// TODO: integrate authz middleware.
return nil
}
func validateNetworkSettings(network settings.NetworkSettings) error {
if err := auth.ValidateListenAddress(network.ListenAddress); err != nil {
return err
}
if err := auth.ValidateHostname(network.PublicHostname); err != nil {
return err
}
if err := auth.ValidateAccessRules(network.Rules); err != nil {
return err
}
switch network.AccessMode {
case settings.AccessModeOpen, settings.AccessModeWhitelist, settings.AccessModeBlacklist:
return nil
default:
return errors.New("network.access_mode must be open, whitelist, or blacklist")
}
}
@@ -0,0 +1,153 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
type networkResponseTest struct {
Network settings.NetworkSettings `json:"network"`
}
func TestNetworkGetReturnsDefaults(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/network", 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 networkResponseTest
if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Network.ListenAddress != "0.0.0.0" {
t.Fatalf("expected listen_address 0.0.0.0, got %q", payload.Network.ListenAddress)
}
if payload.Network.AccessMode != settings.AccessModeOpen {
t.Fatalf("expected access_mode open, got %q", payload.Network.AccessMode)
}
}
func TestNetworkPutPersistsValidSettings(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
body := []byte(`{
"network": {
"listen_address": "127.0.0.1",
"public_hostname": "",
"access_mode": "open",
"rules": []
}
}`)
request := withSession(httptest.NewRequest(http.MethodPut, "/api/v1/network", bytes.NewReader(body)), 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())
}
getReq := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/network", nil), cookie)
getRec := httptest.NewRecorder()
router.ServeHTTP(getRec, getReq)
if getRec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, getRec.Code)
}
var payload networkResponseTest
if err := json.NewDecoder(getRec.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Network.ListenAddress != "127.0.0.1" {
t.Fatalf("expected listen_address 127.0.0.1, got %q", payload.Network.ListenAddress)
}
}
func TestNetworkPutRejectsInvalidAccessMode(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
body := []byte(`{
"network": {
"listen_address": "0.0.0.0",
"public_hostname": "",
"access_mode": "maybe",
"rules": []
}
}`)
request := withSession(httptest.NewRequest(http.MethodPut, "/api/v1/network", bytes.NewReader(body)), cookie)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, recorder.Code)
}
}
func TestNetworkPutRejectsLockout(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
body := []byte(`{
"network": {
"listen_address": "0.0.0.0",
"public_hostname": "",
"access_mode": "whitelist",
"rules": ["10.0.0.0/8"]
}
}`)
request := withSession(httptest.NewRequest(http.MethodPut, "/api/v1/network", bytes.NewReader(body)), cookie)
request.RemoteAddr = "192.168.1.50:12345"
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d body=%s", http.StatusBadRequest, recorder.Code, recorder.Body.String())
}
var payload apiErrorResponseTest
if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Error == "" {
t.Fatal("expected lockout error message")
}
}
func TestNetworkPutAllowsWhitelistIncludingClient(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
body := []byte(`{
"network": {
"listen_address": "0.0.0.0",
"public_hostname": "",
"access_mode": "whitelist",
"rules": ["192.168.1.50"]
}
}`)
request := withSession(httptest.NewRequest(http.MethodPut, "/api/v1/network", bytes.NewReader(body)), cookie)
request.RemoteAddr = "192.168.1.50:12345"
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())
}
}
+785
View File
@@ -0,0 +1,785 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"strings"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
type nodesResponse struct {
Nodes []settings.Node `json:"nodes"`
}
type nodeResponse struct {
Node settings.Node `json:"node"`
}
type createNodeGenerate struct {
Algorithm string `json:"algorithm"`
RSABits int `json:"rsa_bits"`
KDFRounds int `json:"kdf_rounds"`
}
type createNodeNewGroup struct {
Name string `json:"name"`
}
type createNodeRequest struct {
ID string `json:"id"`
Kind settings.NodeKind `json:"kind"`
Name string `json:"name"`
HostIP string `json:"host_ip"`
Username string `json:"username"`
GroupName string `json:"group_name"`
NewGroup *createNodeNewGroup `json:"new_group"`
Generate *createNodeGenerate `json:"generate"`
PrivateKey string `json:"private_key"`
}
type deleteNodeRequest struct {
CurrentPassword string `json:"current_password"`
TOTPCode string `json:"totp_code"`
}
type nodeLogsResponse struct {
Events []settings.NodeAuditEvent `json:"events"`
}
func (app *App) nodesListHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.authorizeNodesRead(request); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
user, ok := UserFromContext(request.Context())
if !ok {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "authentication required"})
return
}
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
store, err := settings.LoadNodesOrEmpty(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
kindFilter := strings.TrimSpace(request.URL.Query().Get("kind"))
filtered := make([]settings.Node, 0, len(store.Nodes))
for _, node := range store.Nodes {
if kindFilter != "" && string(node.Kind) != kindFilter {
continue
}
if !userCanAccessNode(user, settingsPayload.Groups, node) {
continue
}
filtered = append(filtered, node)
}
writeJSON(writer, http.StatusOK, nodesResponse{Nodes: filtered})
}
func (app *App) nodesGetHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.authorizeNodesRead(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
}
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
}
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
}
writeJSON(writer, http.StatusOK, nodeResponse{Node: node})
return
}
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "node not found"})
}
type nodeSSHTestResponse struct {
OK bool `json:"ok"`
Message string `json:"message"`
}
func (app *App) nodesTestSSHHandler(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
}
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
}
if err := auth.TestSSHConnection(node.HostIP, node.Username, keyEntry.PrivateKey, keyEntry.Passphrase); err != nil {
_ = appendNodeAudit(app.ConfigDir, settings.NodeAuditActionTestSSH, user.Username, node, err.Error())
writeJSON(writer, http.StatusOK, nodeSSHTestResponse{
OK: false,
Message: err.Error(),
})
return
}
_ = appendNodeAudit(app.ConfigDir, settings.NodeAuditActionTestSSH, user.Username, node, "SSH connection succeeded")
writeJSON(writer, http.StatusOK, nodeSSHTestResponse{
OK: true,
Message: "SSH connection succeeded",
})
}
type patchNodeRequest struct {
HealthCheckIntervalSeconds *int `json:"health_check_interval_seconds"`
}
func (app *App) nodesPatchHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.authorizeNodesUpdate(request); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
user, ok := UserFromContext(request.Context())
if !ok {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "authentication required"})
return
}
nodeID := strings.TrimSpace(request.PathValue("id"))
if nodeID == "" {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "missing node id"})
return
}
var payload patchNodeRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
if payload.HealthCheckIntervalSeconds == nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{
Error: "health_check_interval_seconds is required",
})
return
}
if *payload.HealthCheckIntervalSeconds < 0 {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{
Error: "health_check_interval_seconds must be >= 0",
})
return
}
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
store, err := settings.LoadNodesOrEmpty(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
nodeIndex := -1
var node settings.Node
for index, candidate := range store.Nodes {
if candidate.ID != nodeID {
continue
}
node = candidate
nodeIndex = index
break
}
if nodeIndex < 0 {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "node not found"})
return
}
if !userCanAccessNode(user, settingsPayload.Groups, node) {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: "permission denied"})
return
}
node.HealthCheckIntervalSeconds = *payload.HealthCheckIntervalSeconds
store.Nodes[nodeIndex] = node
if err := settings.SaveNodes(app.ConfigDir, store); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
_ = appendNodeAudit(
app.ConfigDir,
settings.NodeAuditActionUpdate,
user.Username,
node,
fmt.Sprintf("health_check_interval_seconds=%d", node.HealthCheckIntervalSeconds),
)
writeJSON(writer, http.StatusOK, nodeResponse{Node: node})
}
func (app *App) nodesHealthCheckHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.authorizeNodesExec(request); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
if err := app.requireKey(); err != nil {
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()})
return
}
if app.HealthChecker == nil {
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: "health checker unavailable"})
return
}
user, ok := UserFromContext(request.Context())
if !ok {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "authentication required"})
return
}
nodeID := strings.TrimSpace(request.PathValue("id"))
if nodeID == "" {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "missing node id"})
return
}
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
store, err := settings.LoadNodesOrEmpty(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
var node settings.Node
found := false
for _, candidate := range store.Nodes {
if candidate.ID != nodeID {
continue
}
node = candidate
found = true
break
}
if !found {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "node not found"})
return
}
if !userCanAccessNode(user, settingsPayload.Groups, node) {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: "permission denied"})
return
}
updated, err := app.HealthChecker.CheckNodeByID(nodeID, user.Username)
if err != nil {
if strings.Contains(err.Error(), "already running") {
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, nodeResponse{Node: updated})
}
func (app *App) nodesCreateHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.authorizeNodesExec(request); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
if err := app.requireKey(); err != nil {
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()})
return
}
var payload createNodeRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
node, keyEntry, err := buildNodeFromCreateRequest(payload)
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
}
if payload.NewGroup != nil {
groupName := strings.TrimSpace(payload.NewGroup.Name)
if groupName == "" {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "new_group.name is required"})
return
}
for _, existing := range settingsPayload.Groups {
if existing.Name == groupName {
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "group already exists"})
return
}
}
newGroup := settings.Group{
Name: groupName,
ScopeKind: settings.GroupScopeNode,
ScopeName: node.ID,
Permissions: []string{},
}
settingsPayload.Groups = append(settingsPayload.Groups, newGroup)
node.GroupName = groupName
} else {
groupName := strings.TrimSpace(payload.GroupName)
if groupName == "" {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "group_name is required"})
return
}
found := false
for _, existing := range settingsPayload.Groups {
if existing.Name == groupName {
found = true
break
}
}
if !found {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "group not found"})
return
}
node.GroupName = groupName
}
store, err := settings.LoadNodesOrEmpty(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
for _, existing := range store.Nodes {
if existing.ID == node.ID {
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "node id already exists"})
return
}
if existing.Name == node.Name && existing.Kind == node.Kind {
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "node name already exists for this kind"})
return
}
}
keyStore, err := settings.LoadNodeKeysOrEmpty(app.ConfigDir, app.Key)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
store.Nodes = append(store.Nodes, node)
keyStore.Keys = append(keyStore.Keys, keyEntry)
if payload.NewGroup != nil {
if err := settings.SaveSettings(app.ConfigDir, settingsPayload); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
}
if err := settings.SaveNodes(app.ConfigDir, store); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
if err := settings.SaveNodeKeys(app.ConfigDir, keyStore, app.Key); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
actorUsername := ""
if actor, ok := UserFromContext(request.Context()); ok {
actorUsername = actor.Username
}
_ = appendNodeAudit(app.ConfigDir, settings.NodeAuditActionCreate, actorUsername, node, "")
writeJSON(writer, http.StatusCreated, nodeResponse{Node: node})
}
func (app *App) nodesDeleteHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.authorizeNodesDelete(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 deleteNodeRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
if err := app.requireInlineReauth(request, payload.CurrentPassword, payload.TOTPCode); err != nil {
writeJSON(writer, http.StatusUnauthorized, 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 deleted settings.Node
remaining := make([]settings.Node, 0, len(store.Nodes))
found := false
for _, candidate := range store.Nodes {
if candidate.ID != nodeID {
remaining = append(remaining, candidate)
continue
}
found = true
deleted = candidate
if !userCanAccessNode(user, settingsPayload.Groups, candidate) {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: "permission denied"})
return
}
}
if !found {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "node not found"})
return
}
keyStore, err := settings.LoadNodeKeysOrEmpty(app.ConfigDir, app.Key)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
remainingKeys := make([]settings.NodeKeyEntry, 0, len(keyStore.Keys))
for _, entry := range keyStore.Keys {
if entry.NodeID == nodeID {
continue
}
remainingKeys = append(remainingKeys, entry)
}
store.Nodes = remaining
keyStore.Keys = remainingKeys
if err := settings.SaveNodes(app.ConfigDir, store); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
if err := settings.SaveNodeKeys(app.ConfigDir, keyStore, app.Key); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
_ = appendNodeAudit(app.ConfigDir, settings.NodeAuditActionDelete, user.Username, deleted, "")
filtered := make([]settings.Node, 0, len(store.Nodes))
for _, node := range store.Nodes {
if userCanAccessNode(user, settingsPayload.Groups, node) {
filtered = append(filtered, node)
}
}
writeJSON(writer, http.StatusOK, nodesResponse{Nodes: filtered})
}
func (app *App) nodeLogsListHandler(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.LoadNodeAuditOrEmpty(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
kindFilter := strings.TrimSpace(request.URL.Query().Get("kind"))
filtered := make([]settings.NodeAuditEvent, 0, len(store.Events))
for index := len(store.Events) - 1; index >= 0; index-- {
event := store.Events[index]
if kindFilter != "" && string(event.NodeKind) != kindFilter {
continue
}
filtered = append(filtered, event)
}
writeJSON(writer, http.StatusOK, nodeLogsResponse{Events: filtered})
}
func buildNodeFromCreateRequest(payload createNodeRequest) (settings.Node, settings.NodeKeyEntry, error) {
kind := payload.Kind
switch kind {
case settings.NodeKindContainer, settings.NodeKindVM, settings.NodeKindDocker:
// ok
default:
return settings.Node{}, settings.NodeKeyEntry{}, errors.New("kind must be container, vm, or docker")
}
name := strings.TrimSpace(payload.Name)
if name == "" {
return settings.Node{}, settings.NodeKeyEntry{}, errors.New("name is required")
}
hostIP := strings.TrimSpace(payload.HostIP)
if hostIP == "" {
return settings.Node{}, settings.NodeKeyEntry{}, errors.New("host_ip is required")
}
if parsed := net.ParseIP(hostIP); parsed == nil {
return settings.Node{}, settings.NodeKeyEntry{}, errors.New("host_ip must be a valid IPv4 or IPv6 address")
}
username := strings.TrimSpace(payload.Username)
if username == "" {
username = auth.DefaultSSHUsername
}
nodeID := strings.TrimSpace(payload.ID)
if nodeID == "" {
generatedID, err := auth.NewUUID()
if err != nil {
return settings.Node{}, settings.NodeKeyEntry{}, err
}
nodeID = generatedID
} else if !isUUID(nodeID) {
return settings.Node{}, settings.NodeKeyEntry{}, errors.New("id must be a UUID")
}
var generated auth.GeneratedSSHKey
var err error
switch {
case payload.Generate != nil && strings.TrimSpace(payload.PrivateKey) != "":
return settings.Node{}, settings.NodeKeyEntry{}, errors.New("provide either generate or private_key, not both")
case payload.Generate != nil:
generated, err = auth.GenerateSSHKey(
payload.Generate.Algorithm,
payload.Generate.RSABits,
payload.Generate.KDFRounds,
)
case strings.TrimSpace(payload.PrivateKey) != "":
generated, err = auth.ParseSSHPrivateKey(payload.PrivateKey)
default:
return settings.Node{}, settings.NodeKeyEntry{}, errors.New("generate or private_key is required")
}
if err != nil {
return settings.Node{}, settings.NodeKeyEntry{}, err
}
now := time.Now().UTC()
node := settings.Node{
ID: nodeID,
Kind: kind,
Name: name,
HostIP: hostIP,
Username: username,
PublicKey: generated.PublicKey,
KeyAlgo: generated.Algorithm,
CreatedAt: now,
}
keyEntry := settings.NodeKeyEntry{
NodeID: nodeID,
PrivateKey: generated.PrivateKeyPEM,
Algorithm: generated.Algorithm,
RSABits: generated.RSABits,
KDFRounds: generated.KDFRounds,
}
return node, keyEntry, nil
}
func userCanAccessNode(user settings.UserCredential, groups []settings.Group, node settings.Node) bool {
for _, groupName := range user.GroupNames {
if groupName == settings.AdministratorsGroupName {
return true
}
}
for _, groupName := range user.GroupNames {
if groupName == node.GroupName {
return true
}
}
// Users with nodes.read via any group can list nodes they belong to above;
// Administrators already returned. Also allow if any of user's groups has
// scope_kind=node and scope_name matching this node id.
groupByName := make(map[string]settings.Group, len(groups))
for _, group := range groups {
groupByName[group.Name] = group
}
for _, groupName := range user.GroupNames {
group, ok := groupByName[groupName]
if !ok {
continue
}
if group.ScopeKind == settings.GroupScopeNode && group.ScopeName == node.ID {
return true
}
}
return false
}
func appendNodeAudit(
configDir string,
action settings.NodeAuditAction,
actor string,
node settings.Node,
detail string,
) error {
eventID, err := auth.NewUUID()
if err != nil {
return err
}
return settings.AppendNodeAuditEvent(configDir, settings.NodeAuditEvent{
ID: eventID,
At: time.Now().UTC(),
Action: action,
Actor: actor,
NodeID: node.ID,
NodeName: node.Name,
NodeKind: node.Kind,
Detail: detail,
})
}
func isUUID(value string) bool {
if len(value) != 36 {
return false
}
for index, char := range value {
switch index {
case 8, 13, 18, 23:
if char != '-' {
return false
}
default:
if (char < '0' || char > '9') && (char < 'a' || char > 'f') && (char < 'A' || char > 'F') {
return false
}
}
}
return true
}
+784
View File
@@ -0,0 +1,784 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
func TestNodesCreateGeneratesEd25519AndStoresEncryptedKey(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
body := map[string]any{
"id": "11111111-2222-4333-8444-555555555555",
"kind": "container",
"name": "ct-alpha",
"host_ip": "192.168.10.20",
"username": "clustercanvas",
"group_name": settings.AdministratorsGroupName,
"generate": map[string]any{
"algorithm": "ed25519",
"kdf_rounds": 100,
},
}
payload, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal: %v", err)
}
request := withSession(
httptest.NewRequest(http.MethodPost, "/api/v1/nodes", bytes.NewReader(payload)),
cookie,
)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusCreated {
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
}
var response nodeResponse
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatalf("decode: %v", err)
}
if response.Node.ID != "11111111-2222-4333-8444-555555555555" {
t.Fatalf("id = %q", response.Node.ID)
}
if response.Node.PublicKey == "" {
t.Fatal("expected public key")
}
if strings.Contains(recorder.Body.String(), "BEGIN OPENSSH PRIVATE KEY") {
t.Fatal("private key must not appear in API response")
}
keyBytes, err := settings.KeyFromEnv()
if err != nil {
t.Fatalf("KeyFromEnv: %v", err)
}
keyStore, err := settings.LoadNodeKeys(configDir, keyBytes)
if err != nil {
t.Fatalf("LoadNodeKeys: %v", err)
}
if len(keyStore.Keys) != 1 {
t.Fatalf("keys = %d", len(keyStore.Keys))
}
if keyStore.Keys[0].NodeID != response.Node.ID {
t.Fatalf("key node id = %q", keyStore.Keys[0].NodeID)
}
if !strings.Contains(keyStore.Keys[0].PrivateKey, "BEGIN OPENSSH PRIVATE KEY") {
t.Fatal("expected openssh private key in encrypted store")
}
if keyStore.Keys[0].KDFRounds != 100 {
t.Fatalf("kdf rounds = %d", keyStore.Keys[0].KDFRounds)
}
}
func TestNodesCreateRejectsWithoutNodesExec(t *testing.T) {
configDir := t.TempDir()
_ = seedCompletedSetup(t, configDir)
keyBytes, err := settings.KeyFromEnv()
if err != nil {
t.Fatalf("KeyFromEnv: %v", err)
}
now := time.Now().UTC()
passwordHash, err := auth.HashPassword("correct horse battery staple extra")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
payload := settings.Settings{
SetupCompleted: true,
Groups: []settings.Group{
{
Name: settings.AdministratorsGroupName,
ScopeKind: settings.GroupScopeGroup,
ScopeName: "Admin Group",
Permissions: allPermissionList(),
},
{
Name: "Readers",
ScopeKind: settings.GroupScopeGroup,
ScopeName: "Readers",
Permissions: []string{"nodes.read"},
},
},
Security: settings.DefaultSecuritySettings(),
Network: settings.DefaultNetworkSettings(),
}
if err := settings.SaveSettings(configDir, payload); err != nil {
t.Fatalf("SaveSettings: %v", err)
}
if err := settings.SavePasswords(configDir, settings.PasswordStore{
Users: []settings.UserCredential{
{
ID: "admin-id",
Username: "Admin",
PasswordHash: passwordHash,
Enabled: true,
GroupNames: []string{settings.AdministratorsGroupName},
CreatedAt: now,
PasswordChangedAt: now,
},
{
ID: "reader-id",
Username: "reader",
PasswordHash: passwordHash,
Enabled: true,
GroupNames: []string{"Readers"},
CreatedAt: now,
PasswordChangedAt: now,
},
},
}, keyBytes); err != nil {
t.Fatalf("SavePasswords: %v", err)
}
router := NewRouter(configDir)
loginRecorder := httptest.NewRecorder()
loginRequest := httptest.NewRequest(
http.MethodPost,
"/api/v1/auth/login",
bytes.NewReader([]byte(`{"username":"reader","password":"correct horse battery staple extra"}`)),
)
router.ServeHTTP(loginRecorder, loginRequest)
if loginRecorder.Code != http.StatusOK {
t.Fatalf("login status = %d body=%s", loginRecorder.Code, loginRecorder.Body.String())
}
var readerCookie *http.Cookie
for _, candidate := range loginRecorder.Result().Cookies() {
if candidate.Name == auth.SessionCookieName {
readerCookie = candidate
}
}
if readerCookie == nil {
t.Fatal("missing session cookie")
}
createBody := []byte(`{
"kind":"container",
"name":"ct-beta",
"host_ip":"10.0.0.2",
"group_name":"Readers",
"generate":{"algorithm":"ed25519"}
}`)
request := withSession(
httptest.NewRequest(http.MethodPost, "/api/v1/nodes", bytes.NewReader(createBody)),
readerCookie,
)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusForbidden {
t.Fatalf("status = %d body=%s", recorder.Code, recorder.Body.String())
}
}
func TestNodesCreateRSAAndListByKind(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
body := []byte(`{
"kind":"vm",
"name":"vm-one",
"host_ip":"10.0.0.8",
"group_name":"Administrators",
"generate":{"algorithm":"rsa","rsa_bits":2048}
}`)
request := withSession(
httptest.NewRequest(http.MethodPost, "/api/v1/nodes", bytes.NewReader(body)),
cookie,
)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusCreated {
t.Fatalf("create status = %d body=%s", recorder.Code, recorder.Body.String())
}
listRequest := withSession(
httptest.NewRequest(http.MethodGet, "/api/v1/nodes?kind=vm", nil),
cookie,
)
listRecorder := httptest.NewRecorder()
router.ServeHTTP(listRecorder, listRequest)
if listRecorder.Code != http.StatusOK {
t.Fatalf("list status = %d", listRecorder.Code)
}
var list nodesResponse
if err := json.Unmarshal(listRecorder.Body.Bytes(), &list); err != nil {
t.Fatalf("decode: %v", err)
}
if len(list.Nodes) != 1 || list.Nodes[0].Name != "vm-one" {
t.Fatalf("nodes = %+v", list.Nodes)
}
if list.Nodes[0].KeyAlgo != "rsa" {
t.Fatalf("key algo = %q", list.Nodes[0].KeyAlgo)
}
emptyRequest := withSession(
httptest.NewRequest(http.MethodGet, "/api/v1/nodes?kind=docker", nil),
cookie,
)
emptyRecorder := httptest.NewRecorder()
router.ServeHTTP(emptyRecorder, emptyRequest)
var emptyList nodesResponse
if err := json.Unmarshal(emptyRecorder.Body.Bytes(), &emptyList); err != nil {
t.Fatalf("decode: %v", err)
}
if len(emptyList.Nodes) != 0 {
t.Fatalf("expected no docker nodes, got %d", len(emptyList.Nodes))
}
}
func TestNodesCreateWithNewGroup(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
body := []byte(`{
"kind":"docker",
"name":"docker-host",
"host_ip":"10.1.1.1",
"new_group":{"name":"Docker Hosts"},
"generate":{"algorithm":"ed25519","kdf_rounds":64}
}`)
request := withSession(
httptest.NewRequest(http.MethodPost, "/api/v1/nodes", bytes.NewReader(body)),
cookie,
)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusCreated {
t.Fatalf("status = %d body=%s", recorder.Code, recorder.Body.String())
}
var response nodeResponse
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatalf("decode: %v", err)
}
if response.Node.GroupName != "Docker Hosts" {
t.Fatalf("group = %q", response.Node.GroupName)
}
settingsPayload, err := settings.LoadSettings(configDir)
if err != nil {
t.Fatalf("LoadSettings: %v", err)
}
found := false
for _, group := range settingsPayload.Groups {
if group.Name == "Docker Hosts" {
found = true
if group.ScopeKind != settings.GroupScopeNode {
t.Fatalf("scope kind = %q", group.ScopeKind)
}
if group.ScopeName != response.Node.ID {
t.Fatalf("scope name = %q want %q", group.ScopeName, response.Node.ID)
}
if len(group.Permissions) != 0 {
t.Fatalf("expected empty permissions, got %v", group.Permissions)
}
}
}
if !found {
t.Fatal("new group not saved")
}
}
func TestNodesTestSSHReportsFailureForUnreachableHost(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
createBody := []byte(`{
"id":"aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
"kind":"container",
"name":"unreachable",
"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())
}
// 127.0.0.1 with no SSH listener (or auth failure) should not panic; expect ok=false.
testRequest := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/nodes/aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee/test-ssh",
nil,
),
cookie,
)
testRecorder := httptest.NewRecorder()
router.ServeHTTP(testRecorder, testRequest)
if testRecorder.Code != http.StatusOK {
t.Fatalf("test status = %d body=%s", testRecorder.Code, testRecorder.Body.String())
}
var response nodeSSHTestResponse
if err := json.Unmarshal(testRecorder.Body.Bytes(), &response); err != nil {
t.Fatalf("decode: %v", err)
}
if response.OK {
t.Fatal("expected SSH test to fail against localhost without matching key/auth")
}
if response.Message == "" {
t.Fatal("expected failure message")
}
audit, err := settings.LoadNodeAudit(configDir)
if err != nil {
t.Fatalf("LoadNodeAudit: %v", err)
}
foundTestSSH := false
for _, event := range audit.Events {
if event.Action == settings.NodeAuditActionTestSSH && event.NodeID == "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" {
foundTestSSH = true
}
}
if !foundTestSSH {
t.Fatal("expected test_ssh audit event")
}
}
func TestNodesResolveCommandsRejectsInvalidNames(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
createBody := []byte(`{
"id":"dddddddd-bbbb-4ccc-8ddd-eeeeeeeeeeee",
"kind":"container",
"name":"resolve-test",
"host_ip":"127.0.0.1",
"group_name":"Administrators",
"generate":{"algorithm":"ed25519"}
}`)
createRequest := withSession(
httptest.NewRequest(http.MethodPost, "/api/v1/nodes", bytes.NewReader(createBody)),
cookie,
)
createRecorder := httptest.NewRecorder()
router.ServeHTTP(createRecorder, createRequest)
if createRecorder.Code != http.StatusCreated {
t.Fatalf("create status = %d body=%s", createRecorder.Code, createRecorder.Body.String())
}
badBody := []byte(`{"names":["/usr/bin/apt"]}`)
badRequest := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/nodes/dddddddd-bbbb-4ccc-8ddd-eeeeeeeeeeee/resolve-commands",
bytes.NewReader(badBody),
),
cookie,
)
badRecorder := httptest.NewRecorder()
router.ServeHTTP(badRecorder, badRequest)
if badRecorder.Code != http.StatusBadRequest {
t.Fatalf("status = %d body=%s", badRecorder.Code, badRecorder.Body.String())
}
if !strings.Contains(badRecorder.Body.String(), "basename") {
t.Fatalf("expected basename error, got %s", badRecorder.Body.String())
}
emptyBody := []byte(`{"names":[]}`)
emptyRequest := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/nodes/dddddddd-bbbb-4ccc-8ddd-eeeeeeeeeeee/resolve-commands",
bytes.NewReader(emptyBody),
),
cookie,
)
emptyRecorder := httptest.NewRecorder()
router.ServeHTTP(emptyRecorder, emptyRequest)
if emptyRecorder.Code != http.StatusBadRequest {
t.Fatalf("empty status = %d body=%s", emptyRecorder.Code, emptyRecorder.Body.String())
}
}
func TestNodesDeleteRequiresPermissionAndInlineReauth(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
createBody := []byte(`{
"id":"bbbbbbbb-bbbb-4ccc-8ddd-eeeeeeeeeeee",
"kind":"vm",
"name":"vm-delete-me",
"host_ip":"10.0.0.9",
"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())
}
missingAuthRequest := withSession(
httptest.NewRequest(
http.MethodDelete,
"/api/v1/nodes/bbbbbbbb-bbbb-4ccc-8ddd-eeeeeeeeeeee",
bytes.NewReader([]byte(`{}`)),
),
cookie,
)
missingAuthRecorder := httptest.NewRecorder()
router.ServeHTTP(missingAuthRecorder, missingAuthRequest)
if missingAuthRecorder.Code != http.StatusUnauthorized {
t.Fatalf("missing reauth status = %d body=%s", missingAuthRecorder.Code, missingAuthRecorder.Body.String())
}
deleteBody := []byte(`{"current_password":"correct horse battery staple extra"}`)
deleteRequest := withSession(
httptest.NewRequest(
http.MethodDelete,
"/api/v1/nodes/bbbbbbbb-bbbb-4ccc-8ddd-eeeeeeeeeeee",
bytes.NewReader(deleteBody),
),
cookie,
)
deleteRecorder := httptest.NewRecorder()
router.ServeHTTP(deleteRecorder, deleteRequest)
if deleteRecorder.Code != http.StatusOK {
t.Fatalf("delete status = %d body=%s", deleteRecorder.Code, deleteRecorder.Body.String())
}
var list nodesResponse
if err := json.Unmarshal(deleteRecorder.Body.Bytes(), &list); err != nil {
t.Fatalf("decode: %v", err)
}
for _, node := range list.Nodes {
if node.ID == "bbbbbbbb-bbbb-4ccc-8ddd-eeeeeeeeeeee" {
t.Fatal("deleted node still listed")
}
}
store, err := settings.LoadNodes(configDir)
if err != nil {
t.Fatalf("LoadNodes: %v", err)
}
for _, node := range store.Nodes {
if node.ID == "bbbbbbbb-bbbb-4ccc-8ddd-eeeeeeeeeeee" {
t.Fatal("node still on disk")
}
}
keyBytes, err := settings.KeyFromEnv()
if err != nil {
t.Fatalf("KeyFromEnv: %v", err)
}
keyStore, err := settings.LoadNodeKeys(configDir, keyBytes)
if err != nil {
t.Fatalf("LoadNodeKeys: %v", err)
}
for _, entry := range keyStore.Keys {
if entry.NodeID == "bbbbbbbb-bbbb-4ccc-8ddd-eeeeeeeeeeee" {
t.Fatal("private key still on disk")
}
}
audit, err := settings.LoadNodeAudit(configDir)
if err != nil {
t.Fatalf("LoadNodeAudit: %v", err)
}
foundDelete := false
foundCreate := false
for _, event := range audit.Events {
if event.NodeID != "bbbbbbbb-bbbb-4ccc-8ddd-eeeeeeeeeeee" {
continue
}
if event.Action == settings.NodeAuditActionCreate {
foundCreate = true
}
if event.Action == settings.NodeAuditActionDelete {
foundDelete = true
}
}
if !foundCreate || !foundDelete {
t.Fatalf("audit create=%v delete=%v events=%+v", foundCreate, foundDelete, audit.Events)
}
}
func TestNodesDeleteRejectsWithoutNodesDelete(t *testing.T) {
configDir := t.TempDir()
_ = seedCompletedSetup(t, configDir)
keyBytes, err := settings.KeyFromEnv()
if err != nil {
t.Fatalf("KeyFromEnv: %v", err)
}
now := time.Now().UTC()
passwordHash, err := auth.HashPassword("correct horse battery staple extra")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
payload := settings.Settings{
SetupCompleted: true,
Groups: []settings.Group{
{
Name: settings.AdministratorsGroupName,
ScopeKind: settings.GroupScopeGroup,
ScopeName: "Admin Group",
Permissions: allPermissionList(),
},
{
Name: "Executors",
ScopeKind: settings.GroupScopeGroup,
ScopeName: "Executors",
Permissions: []string{"nodes.read", "nodes.exec"},
},
},
Security: settings.DefaultSecuritySettings(),
Network: settings.DefaultNetworkSettings(),
}
if err := settings.SaveSettings(configDir, payload); err != nil {
t.Fatalf("SaveSettings: %v", err)
}
if err := settings.SavePasswords(configDir, settings.PasswordStore{
Users: []settings.UserCredential{
{
ID: "admin-id",
Username: "Admin",
PasswordHash: passwordHash,
Enabled: true,
GroupNames: []string{settings.AdministratorsGroupName},
CreatedAt: now,
PasswordChangedAt: now,
},
{
ID: "exec-id",
Username: "executor",
PasswordHash: passwordHash,
Enabled: true,
GroupNames: []string{"Executors"},
CreatedAt: now,
PasswordChangedAt: now,
},
},
}, keyBytes); err != nil {
t.Fatalf("SavePasswords: %v", err)
}
if err := settings.SaveNodes(configDir, settings.NodeStore{
Nodes: []settings.Node{
{
ID: "cccccccc-bbbb-4ccc-8ddd-eeeeeeeeeeee",
Kind: settings.NodeKindContainer,
Name: "ct-keep",
HostIP: "10.0.0.3",
Username: "clustercanvas",
GroupName: "Executors",
PublicKey: "ssh-ed25519 AAAA",
KeyAlgo: "ed25519",
CreatedAt: now,
},
},
}); err != nil {
t.Fatalf("SaveNodes: %v", err)
}
if err := settings.SaveNodeKeys(configDir, settings.NodeKeyStore{
Keys: []settings.NodeKeyEntry{
{
NodeID: "cccccccc-bbbb-4ccc-8ddd-eeeeeeeeeeee",
PrivateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n-----END OPENSSH PRIVATE KEY-----\n",
Algorithm: "ed25519",
},
},
}, keyBytes); err != nil {
t.Fatalf("SaveNodeKeys: %v", err)
}
router := NewRouter(configDir)
loginRecorder := httptest.NewRecorder()
loginRequest := httptest.NewRequest(
http.MethodPost,
"/api/v1/auth/login",
bytes.NewReader([]byte(`{"username":"executor","password":"correct horse battery staple extra"}`)),
)
router.ServeHTTP(loginRecorder, loginRequest)
if loginRecorder.Code != http.StatusOK {
t.Fatalf("login status = %d body=%s", loginRecorder.Code, loginRecorder.Body.String())
}
var execCookie *http.Cookie
for _, candidate := range loginRecorder.Result().Cookies() {
if candidate.Name == auth.SessionCookieName {
execCookie = candidate
}
}
if execCookie == nil {
t.Fatal("missing session cookie")
}
deleteBody := []byte(`{"current_password":"correct horse battery staple extra"}`)
request := withSession(
httptest.NewRequest(
http.MethodDelete,
"/api/v1/nodes/cccccccc-bbbb-4ccc-8ddd-eeeeeeeeeeee",
bytes.NewReader(deleteBody),
),
execCookie,
)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusForbidden {
t.Fatalf("status = %d body=%s", recorder.Code, recorder.Body.String())
}
}
func TestNodeLogsListFiltersByKind(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
for _, body := range []string{
`{"kind":"container","name":"ct-log","host_ip":"10.0.0.1","group_name":"Administrators","generate":{"algorithm":"ed25519"}}`,
`{"kind":"vm","name":"vm-log","host_ip":"10.0.0.2","group_name":"Administrators","generate":{"algorithm":"ed25519"}}`,
} {
request := withSession(
httptest.NewRequest(http.MethodPost, "/api/v1/nodes", bytes.NewReader([]byte(body))),
cookie,
)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusCreated {
t.Fatalf("create status = %d body=%s", recorder.Code, recorder.Body.String())
}
}
allRequest := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/node-logs", nil), cookie)
allRecorder := httptest.NewRecorder()
router.ServeHTTP(allRecorder, allRequest)
if allRecorder.Code != http.StatusOK {
t.Fatalf("logs status = %d body=%s", allRecorder.Code, allRecorder.Body.String())
}
var allLogs nodeLogsResponse
if err := json.Unmarshal(allRecorder.Body.Bytes(), &allLogs); err != nil {
t.Fatalf("decode: %v", err)
}
if len(allLogs.Events) < 2 {
t.Fatalf("expected at least 2 events, got %d", len(allLogs.Events))
}
vmRequest := withSession(
httptest.NewRequest(http.MethodGet, "/api/v1/node-logs?kind=vm", nil),
cookie,
)
vmRecorder := httptest.NewRecorder()
router.ServeHTTP(vmRecorder, vmRequest)
var vmLogs nodeLogsResponse
if err := json.Unmarshal(vmRecorder.Body.Bytes(), &vmLogs); err != nil {
t.Fatalf("decode: %v", err)
}
if len(vmLogs.Events) == 0 {
t.Fatal("expected vm events")
}
for _, event := range vmLogs.Events {
if event.NodeKind != settings.NodeKindVM {
t.Fatalf("unexpected kind %q", event.NodeKind)
}
}
}
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,
})
}
+88
View File
@@ -0,0 +1,88 @@
package api
import (
"context"
"errors"
"net/http"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
const reauthRequiredMessage = "reauth_required"
type reauthRequest struct {
Password string `json:"password"`
TOTPCode string `json:"totp_code"`
}
func SessionFromContext(ctx context.Context) (settings.SessionRecord, bool) {
session, ok := ctx.Value(contextSessionKey).(settings.SessionRecord)
return session, ok
}
func verifyActorCredentials(user settings.UserCredential, password string, totpCode string, security settings.SecuritySettings) error {
ok, err := auth.VerifyPassword(password, user.PasswordHash)
if err != nil || !ok {
return errors.New("invalid credentials")
}
requiresTOTP := security.TotpEnabled && user.TOTPConfirmed && user.TOTPSecret != ""
if requiresTOTP {
if !auth.VerifyTOTPCode(user.TOTPSecret, totpCode) {
return errors.New("invalid or missing TOTP code")
}
}
return nil
}
func (app *App) requireInlineReauth(
request *http.Request,
password string,
totpCode string,
) error {
user, ok := UserFromContext(request.Context())
if !ok {
return errors.New("authentication required")
}
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
return err
}
security := effectiveSecurity(settingsPayload.Security)
return verifyActorCredentials(user, password, totpCode, security)
}
// requireGraceReauth returns an error with message reauth_required when the
// session is outside the grace window. When reauth_sensitive_actions is off, it
// is a no-op.
func (app *App) requireGraceReauth(request *http.Request) error {
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
return err
}
security := effectiveSecurity(settingsPayload.Security)
if !security.ReauthSensitiveActions {
return nil
}
session, ok := SessionFromContext(request.Context())
if !ok {
return errors.New("authentication required")
}
grace := time.Duration(security.ReauthGraceMinutes) * time.Minute
elapsed := time.Since(session.LastReauthAt)
// Allow a short post-reauth window so grace=0 still supports reauth-then-retry.
const justReauthedWindow = 30 * time.Second
if elapsed <= grace || elapsed <= justReauthedWindow {
return nil
}
return errors.New(reauthRequiredMessage)
}
func writeReauthRequired(writer http.ResponseWriter) {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: reauthRequiredMessage})
}
+90
View File
@@ -0,0 +1,90 @@
package api
import (
"net/http"
)
const defaultAllowedOrigin = "http://localhost:5173"
// NewRouter builds the HTTP API with setup gating, auth, and network middleware.
func NewRouter(configDir string) http.Handler {
handler, _ := NewRouterWithApp(configDir)
return handler
}
// NewRouterWithApp builds the router and returns the App for lifecycle control (scheduler stop).
func NewRouterWithApp(configDir string) (http.Handler, *App) {
app, err := newApp(configDir)
if err != nil {
// newApp currently always succeeds; keep signature for future.
panic(err)
}
if app.Executor != nil {
app.Executor.StartScheduler()
}
if app.HealthChecker != nil {
app.HealthChecker.StartScheduler()
}
mux := http.NewServeMux()
mux.HandleFunc("GET /health", HealthHandler)
mux.HandleFunc("GET /api/v1/status", StatusHandler)
mux.HandleFunc("GET /api/v1/setup/status", app.setupStatusHandler)
mux.HandleFunc("POST /api/v1/setup/totp/begin", app.setupTOTPBeginHandler)
mux.HandleFunc("POST /api/v1/setup/totp/verify", app.setupTOTPVerifyHandler)
mux.HandleFunc("POST /api/v1/setup/network/preview", app.setupPreviewHandler)
mux.HandleFunc("POST /api/v1/setup/complete", app.setupCompleteHandler)
mux.HandleFunc("POST /api/v1/auth/login", app.loginHandler)
mux.HandleFunc("POST /api/v1/auth/logout", app.logoutHandler)
mux.HandleFunc("GET /api/v1/auth/me", app.meHandler)
mux.HandleFunc("POST /api/v1/auth/reauth", app.reauthHandler)
mux.HandleFunc("PUT /api/v1/me/password", app.mePasswordHandler)
mux.HandleFunc("POST /api/v1/me/totp/begin", app.meTOTPBeginHandler)
mux.HandleFunc("POST /api/v1/me/totp/confirm", app.meTOTPConfirmHandler)
mux.HandleFunc("POST /api/v1/me/totp/disable", app.meTOTPDisableHandler)
mux.HandleFunc("GET /api/v1/groups", groupsGetHandler(configDir))
mux.HandleFunc("POST /api/v1/groups", groupsUpsertHandler(configDir))
mux.HandleFunc("DELETE /api/v1/groups", groupsDeleteHandler(configDir))
mux.HandleFunc("GET /api/v1/actions", actionsGetHandler(configDir))
mux.HandleFunc("POST /api/v1/actions", actionsCreateHandler(configDir))
mux.HandleFunc("PATCH /api/v1/actions", actionsPatchHandler(configDir))
mux.HandleFunc("DELETE /api/v1/actions", actionsDeleteHandler(configDir))
mux.HandleFunc("GET /api/v1/security", securityGetHandler(configDir))
mux.HandleFunc("PUT /api/v1/security", securityPutHandler(configDir))
mux.HandleFunc("GET /api/v1/network", networkGetHandler(configDir))
mux.HandleFunc("PUT /api/v1/network", networkPutHandler(configDir))
mux.HandleFunc("GET /api/v1/users", app.usersGetHandler)
mux.HandleFunc("POST /api/v1/users", app.usersCreateHandler)
mux.HandleFunc("PATCH /api/v1/users", app.usersPatchHandler)
mux.HandleFunc("DELETE /api/v1/users", app.usersDeleteHandler)
mux.HandleFunc("GET /api/v1/nodes", app.nodesListHandler)
mux.HandleFunc("POST /api/v1/nodes", app.nodesCreateHandler)
mux.HandleFunc("GET /api/v1/nodes/{id}", app.nodesGetHandler)
mux.HandleFunc("PATCH /api/v1/nodes/{id}", app.nodesPatchHandler)
mux.HandleFunc("DELETE /api/v1/nodes/{id}", app.nodesDeleteHandler)
mux.HandleFunc("POST /api/v1/nodes/{id}/test-ssh", app.nodesTestSSHHandler)
mux.HandleFunc("POST /api/v1/nodes/{id}/health-check", app.nodesHealthCheckHandler)
mux.HandleFunc("POST /api/v1/nodes/{id}/resolve-commands", app.nodesResolveCommandsHandler)
mux.HandleFunc("GET /api/v1/nodes/{id}/action-groups", app.actionGroupsListHandler)
mux.HandleFunc("POST /api/v1/nodes/{id}/action-groups", app.actionGroupsCreateHandler)
mux.HandleFunc("PATCH /api/v1/nodes/{id}/action-groups/{gid}", app.actionGroupsPatchHandler)
mux.HandleFunc("DELETE /api/v1/nodes/{id}/action-groups/{gid}", app.actionGroupsDeleteHandler)
mux.HandleFunc("POST /api/v1/nodes/{id}/action-groups/{gid}/items", app.actionGroupItemsCreateHandler)
mux.HandleFunc("PATCH /api/v1/nodes/{id}/action-groups/{gid}/items/{iid}", app.actionGroupItemsPatchHandler)
mux.HandleFunc("POST /api/v1/nodes/{id}/action-groups/{gid}/items/{iid}/clone", app.actionGroupItemsCloneHandler)
mux.HandleFunc("DELETE /api/v1/nodes/{id}/action-groups/{gid}/items/{iid}", app.actionGroupItemsDeleteHandler)
mux.HandleFunc("PUT /api/v1/nodes/{id}/action-groups/{gid}/items/order", app.actionGroupItemsOrderHandler)
mux.HandleFunc("POST /api/v1/nodes/{id}/action-groups/{gid}/run", app.actionGroupsRunHandler)
mux.HandleFunc("POST /api/v1/nodes/{id}/action-groups/{gid}/items/{iid}/run", app.actionGroupItemsRunHandler)
mux.HandleFunc("GET /api/v1/nodes/{id}/action-logs", app.actionLogsListHandler)
mux.HandleFunc("GET /api/v1/nodes/{id}/action-logs/{filename}", app.actionLogsGetHandler)
mux.HandleFunc("GET /api/v1/node-logs", app.nodeLogsListHandler)
mux.HandleFunc("GET /api/v1/auth-logs", app.authLogsListHandler)
return app.withCORS(app.withMiddleware(mux)), app
}
+98
View File
@@ -0,0 +1,98 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
const (
minIdleTimeoutMinutes = 1
maxIdleTimeoutMinutes = 240 // 4 hours
minSessionLifetimeHours = 1
minReauthGraceMinutes = 0
)
type securityResponse struct {
Security settings.SecuritySettings `json:"security"`
}
type putSecurityRequest struct {
Security settings.SecuritySettings `json:"security"`
}
func securityGetHandler(configDir string) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
settingsPayload, err := loadSettingsOrDefault(configDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, securityResponse{
Security: effectiveSecurity(settingsPayload.Security),
})
}
}
func securityPutHandler(configDir string) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
if err := authorizeSecurityWrite(request); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
var payload putSecurityRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
if err := validateSecuritySettings(payload.Security); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
settingsPayload, err := loadSettingsOrDefault(configDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
settingsPayload.Security = payload.Security
if err := settings.SaveSettings(configDir, settingsPayload); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, securityResponse{Security: settingsPayload.Security})
}
}
func authorizeSecurityWrite(_ *http.Request) error {
// TODO: integrate authz middleware.
return nil
}
func effectiveSecurity(security settings.SecuritySettings) settings.SecuritySettings {
if err := validateSecuritySettings(security); err != nil {
return settings.DefaultSecuritySettings()
}
return security
}
func validateSecuritySettings(security settings.SecuritySettings) error {
if security.IdleTimeoutMinutes < minIdleTimeoutMinutes ||
security.IdleTimeoutMinutes > maxIdleTimeoutMinutes {
return errors.New("idle_timeout_minutes must be between 1 and 240")
}
if security.SessionLifetimeHours < minSessionLifetimeHours {
return errors.New("session_lifetime_hours must be at least 1")
}
if security.ReauthGraceMinutes < minReauthGraceMinutes {
return errors.New("reauth_grace_minutes must be at least 0")
}
return nil
}
@@ -0,0 +1,152 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
type securityResponseTest struct {
Security settings.SecuritySettings `json:"security"`
}
func TestSecurityGetReturnsDefaultsWhenMissing(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/security", nil), cookie)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, recorder.Code)
}
var payload securityResponseTest
if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
want := settings.DefaultSecuritySettings()
if payload.Security != want {
t.Fatalf("got %#v, want %#v", payload.Security, want)
}
}
func TestSecurityPutRejectsIdleTimeoutZero(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
body := []byte(`{
"security": {
"idle_timeout_minutes": 0,
"session_lifetime_hours": 24,
"reauth_sensitive_actions": false,
"reauth_grace_minutes": 15
}
}`)
request := withSession(httptest.NewRequest(http.MethodPut, "/api/v1/security", bytes.NewReader(body)), cookie)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, recorder.Code)
}
}
func TestSecurityPutRejectsIdleTimeoutAboveMax(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
body := []byte(`{
"security": {
"idle_timeout_minutes": 241,
"session_lifetime_hours": 24,
"reauth_sensitive_actions": false,
"reauth_grace_minutes": 15
}
}`)
request := withSession(httptest.NewRequest(http.MethodPut, "/api/v1/security", bytes.NewReader(body)), cookie)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, recorder.Code)
}
}
func TestSecurityPutRejectsSessionLifetimeZero(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
body := []byte(`{
"security": {
"idle_timeout_minutes": 30,
"session_lifetime_hours": 0,
"reauth_sensitive_actions": false,
"reauth_grace_minutes": 15
}
}`)
request := withSession(httptest.NewRequest(http.MethodPut, "/api/v1/security", bytes.NewReader(body)), cookie)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, recorder.Code)
}
}
func TestSecurityPutRoundTrip(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
body := []byte(`{
"security": {
"idle_timeout_minutes": 45,
"session_lifetime_hours": 12,
"reauth_sensitive_actions": true,
"reauth_grace_minutes": 0,
"totp_enabled": true
}
}`)
putReq := withSession(httptest.NewRequest(http.MethodPut, "/api/v1/security", bytes.NewReader(body)), cookie)
putRec := httptest.NewRecorder()
router.ServeHTTP(putRec, putReq)
if putRec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d body %s", http.StatusOK, putRec.Code, putRec.Body.String())
}
getReq := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/security", nil), cookie)
getRec := httptest.NewRecorder()
router.ServeHTTP(getRec, getReq)
if getRec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, getRec.Code)
}
var payload securityResponseTest
if err := json.NewDecoder(getRec.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
want := settings.SecuritySettings{
IdleTimeoutMinutes: 45,
SessionLifetimeHours: 12,
ReauthSensitiveActions: true,
ReauthGraceMinutes: 0,
TotpEnabled: true,
}
if payload.Security != want {
t.Fatalf("got %#v, want %#v", payload.Security, want)
}
}
+611
View File
@@ -0,0 +1,611 @@
package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"os"
"strings"
"sync"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/health"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/runner"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
type contextKey string
const (
contextUserKey contextKey = "user"
contextSessionKey contextKey = "session"
)
type setupStatusResponse struct {
Completed bool `json:"completed"`
ClientIP string `json:"client_ip"`
}
type setupTOTPBeginRequest struct {
Username string `json:"username"`
}
type setupTOTPBeginResponse struct {
Secret string `json:"secret"`
OTPAuthURL string `json:"otpauth_url"`
}
type setupTOTPVerifyRequest struct {
Secret string `json:"secret"`
Code string `json:"code"`
}
type setupCompleteRequest struct {
Username string `json:"username"`
Password string `json:"password"`
TOTPSecret string `json:"totp_secret"`
TOTPConfirmed bool `json:"totp_confirmed"`
Network settings.NetworkSettings `json:"network"`
Security settings.SecuritySettings `json:"security"`
}
type setupPreviewRequest struct {
Network settings.NetworkSettings `json:"network"`
}
type setupPreviewResponse struct {
WouldLockOut bool `json:"would_lock_out"`
Reason string `json:"reason,omitempty"`
ClientIP string `json:"client_ip"`
}
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
TOTPCode string `json:"totp_code"`
}
type meResponse struct {
UserID string `json:"user_id"`
Username string `json:"username"`
Groups []string `json:"groups"`
TOTPConfirmed bool `json:"totp_confirmed"`
TOTPEnabled bool `json:"totp_enabled"`
Permissions []string `json:"permissions"`
}
// App holds shared API dependencies.
type App struct {
ConfigDir string
Key []byte
Sessions *auth.SessionManager
Executor *runner.Executor
HealthChecker *health.Checker
pendingTOTPMu sync.Mutex
pendingTOTP map[string]string // username -> secret (setup only)
}
func newApp(configDir string) (*App, error) {
key, err := settings.KeyFromEnv()
if err != nil {
// Key is required for setup complete / auth; status still works without it.
app := &App{
ConfigDir: configDir,
pendingTOTP: map[string]string{},
Executor: runner.NewExecutor(configDir, nil),
HealthChecker: health.NewChecker(configDir, nil),
}
return app, nil
}
app := &App{
ConfigDir: configDir,
Key: key,
Sessions: auth.NewSessionManager(configDir, key),
pendingTOTP: map[string]string{},
Executor: runner.NewExecutor(configDir, key),
HealthChecker: health.NewChecker(configDir, key),
}
return app, nil
}
func (app *App) requireKey() error {
if len(app.Key) == 0 {
return fmtErrorfKeyMissing()
}
return nil
}
func fmtErrorfKeyMissing() error {
return errors.New(settings.ConfigKeyEnvVar + " is not set")
}
func (app *App) setupStatusHandler(writer http.ResponseWriter, request *http.Request) {
completed, err := settings.IsSetupCompleted(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, setupStatusResponse{
Completed: completed,
ClientIP: auth.ClientIP(request),
})
}
func (app *App) setupTOTPBeginHandler(writer http.ResponseWriter, request *http.Request) {
completed, err := settings.IsSetupCompleted(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
if completed {
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "setup already completed"})
return
}
var payload setupTOTPBeginRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
username := strings.TrimSpace(payload.Username)
if username == "" {
username = "Admin"
}
key, err := auth.GenerateTOTPSecret(username)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
app.pendingTOTPMu.Lock()
app.pendingTOTP[strings.ToLower(username)] = key.Secret()
app.pendingTOTPMu.Unlock()
writeJSON(writer, http.StatusOK, setupTOTPBeginResponse{
Secret: key.Secret(),
OTPAuthURL: key.URL(),
})
}
func (app *App) setupTOTPVerifyHandler(writer http.ResponseWriter, request *http.Request) {
completed, err := settings.IsSetupCompleted(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
if completed {
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "setup already completed"})
return
}
var payload setupTOTPVerifyRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
if !auth.VerifyTOTPCode(payload.Secret, payload.Code) {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid TOTP code"})
return
}
writeJSON(writer, http.StatusOK, map[string]bool{"verified": true})
}
func (app *App) setupPreviewHandler(writer http.ResponseWriter, request *http.Request) {
var payload setupPreviewRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
clientIP := auth.ClientIP(request)
wouldLockOut, reason := auth.WouldLockOut(clientIP, payload.Network)
writeJSON(writer, http.StatusOK, setupPreviewResponse{
WouldLockOut: wouldLockOut,
Reason: reason,
ClientIP: clientIP,
})
}
func (app *App) setupCompleteHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.requireKey(); err != nil {
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()})
return
}
completed, err := settings.IsSetupCompleted(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
if completed {
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "setup already completed"})
return
}
var payload setupCompleteRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
username := strings.TrimSpace(payload.Username)
if username == "" {
username = "Admin"
}
if err := auth.ValidatePassword(payload.Password, username); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
if err := auth.ValidateListenAddress(payload.Network.ListenAddress); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
if err := auth.ValidateHostname(payload.Network.PublicHostname); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
if err := auth.ValidateAccessRules(payload.Network.Rules); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
switch payload.Network.AccessMode {
case settings.AccessModeOpen, settings.AccessModeWhitelist, settings.AccessModeBlacklist:
default:
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "network.access_mode must be open, whitelist, or blacklist"})
return
}
if err := validateSecuritySettings(payload.Security); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
if payload.TOTPConfirmed {
if strings.TrimSpace(payload.TOTPSecret) == "" {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "totp_secret is required when totp_confirmed is true"})
return
}
payload.Security.TotpEnabled = true
}
passwordHash, err := auth.HashPassword(payload.Password)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
now := time.Now().UTC()
userID, err := newUserID()
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
user := settings.UserCredential{
ID: userID,
Username: username,
PasswordHash: passwordHash,
TOTPSecret: payload.TOTPSecret,
TOTPConfirmed: payload.TOTPConfirmed,
Enabled: true,
GroupNames: []string{settings.AdministratorsGroupName},
CreatedAt: now,
PasswordChangedAt: now,
}
passwordStore := settings.PasswordStore{Users: []settings.UserCredential{user}}
if err := settings.SavePasswords(app.ConfigDir, passwordStore, app.Key); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
if err := settings.SaveSessions(app.ConfigDir, settings.SessionStore{Sessions: []settings.SessionRecord{}}, app.Key); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
adminGroup := settings.Group{
Name: settings.AdministratorsGroupName,
ScopeKind: settings.GroupScopeGroup,
ScopeName: "Admin Group",
Permissions: allPermissionList(),
}
network := settings.EffectiveNetwork(payload.Network)
settingsPayload := settings.Settings{
SetupCompleted: true,
Groups: []settings.Group{adminGroup},
Security: payload.Security,
Network: network,
}
if err := settings.SaveSettings(app.ConfigDir, settingsPayload); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, map[string]any{
"completed": true,
"username": username,
})
}
func (app *App) loginHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.requireKey(); err != nil {
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()})
return
}
if app.Sessions == nil {
app.Sessions = auth.NewSessionManager(app.ConfigDir, app.Key)
}
completed, err := settings.IsSetupCompleted(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
if !completed {
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "setup is not completed"})
return
}
var payload loginRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
store, err := settings.LoadPasswords(app.ConfigDir, app.Key)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
var matched *settings.UserCredential
for index := range store.Users {
user := &store.Users[index]
if strings.EqualFold(user.Username, strings.TrimSpace(payload.Username)) {
matched = user
break
}
}
attemptedUsername := strings.TrimSpace(payload.Username)
if matched == nil || !matched.Enabled {
appendAuthAudit(
app.ConfigDir,
"login",
settings.AuthAuditOutcomeFailure,
settings.AuthAuditCategoryAuth,
attemptedUsername,
attemptedUsername,
"invalid credentials",
)
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid credentials"})
return
}
ok, err := auth.VerifyPassword(payload.Password, matched.PasswordHash)
if err != nil || !ok {
appendAuthAudit(
app.ConfigDir,
"login",
settings.AuthAuditOutcomeFailure,
settings.AuthAuditCategoryAuth,
attemptedUsername,
matched.Username,
"invalid credentials",
)
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid credentials"})
return
}
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
security := effectiveSecurity(settingsPayload.Security)
requiresTOTP := security.TotpEnabled && matched.TOTPConfirmed && matched.TOTPSecret != ""
if requiresTOTP {
if !auth.VerifyTOTPCode(matched.TOTPSecret, payload.TOTPCode) {
appendAuthAudit(
app.ConfigDir,
"login",
settings.AuthAuditOutcomeFailure,
settings.AuthAuditCategoryAuth,
matched.Username,
matched.Username,
"invalid or missing TOTP code",
)
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid or missing TOTP code"})
return
}
}
if _, err := app.Sessions.CreateSession(writer, matched.ID, security); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
appendAuthAudit(
app.ConfigDir,
"login",
settings.AuthAuditOutcomeSuccess,
settings.AuthAuditCategoryAuth,
matched.Username,
matched.Username,
"",
)
writeJSON(writer, http.StatusOK, app.buildMeResponse(*matched, settingsPayload))
}
func (app *App) logoutHandler(writer http.ResponseWriter, request *http.Request) {
actor := actorUsernameFromRequest(request)
if app.Sessions != nil {
_ = app.Sessions.DestroySession(writer, request)
}
if actor != "" {
appendAuthAudit(
app.ConfigDir,
"logout",
settings.AuthAuditOutcomeSuccess,
settings.AuthAuditCategoryAuth,
actor,
actor,
"",
)
}
writeJSON(writer, http.StatusOK, map[string]bool{"ok": true})
}
func (app *App) meHandler(writer http.ResponseWriter, request *http.Request) {
user, ok := UserFromContext(request.Context())
if !ok {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "not authenticated"})
return
}
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, app.buildMeResponse(user, settingsPayload))
}
func (app *App) buildMeResponse(user settings.UserCredential, settingsPayload settings.Settings) meResponse {
security := effectiveSecurity(settingsPayload.Security)
groups := user.GroupNames
if groups == nil {
groups = []string{}
}
permissions := permissionList(user, settingsPayload.Groups)
if permissions == nil {
permissions = []string{}
}
return meResponse{
UserID: user.ID,
Username: user.Username,
Groups: groups,
TOTPConfirmed: user.TOTPConfirmed,
TOTPEnabled: security.TotpEnabled,
Permissions: permissions,
}
}
func UserFromContext(ctx context.Context) (settings.UserCredential, bool) {
user, ok := ctx.Value(contextUserKey).(settings.UserCredential)
return user, ok
}
func allPermissionList() []string {
list := make([]string, 0, len(allowedPermissions))
for permission := range allowedPermissions {
list = append(list, permission)
}
// Stable-ish order for tests: sort manually via known catalog order.
ordered := []string{
"nodes.read",
"nodes.exec",
"nodes.update",
"nodes.delete",
"jobs.read",
"jobs.run",
"actions.create",
"actions.update",
"actions.delete",
"actions.execute",
"users.manage",
"secrets.manage",
"roles.manage",
"logs.read",
}
_ = list
return ordered
}
func newUserID() (string, error) {
return auth.NewRandomID()
}
func loadSettingsOrDefault(configDir string) (settings.Settings, error) {
payload, err := settings.LoadSettings(configDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return settings.Settings{
Groups: []settings.Group{},
Security: settings.DefaultSecuritySettings(),
Network: settings.DefaultNetworkSettings(),
}, nil
}
return settings.Settings{}, err
}
if payload.Groups == nil {
payload.Groups = []settings.Group{}
}
payload.Network = settings.EffectiveNetwork(payload.Network)
changed := false
if migrateLogsReadPermission(&payload) {
changed = true
}
if migrateActionsExecutePermission(&payload) {
changed = true
}
if changed {
if err := settings.SaveSettings(configDir, payload); err != nil {
return settings.Settings{}, err
}
}
return payload, nil
}
// migrateLogsReadPermission grants logs.read to groups that previously could
// view logs via nodes.read or users.manage. Returns true when settings changed.
func migrateLogsReadPermission(payload *settings.Settings) bool {
changed := false
for index := range payload.Groups {
group := &payload.Groups[index]
hasLogsRead := false
hadLogAccess := false
for _, permission := range group.Permissions {
switch permission {
case "logs.read":
hasLogsRead = true
case "nodes.read", "users.manage":
hadLogAccess = true
}
}
if hasLogsRead || !hadLogAccess {
continue
}
group.Permissions = append(group.Permissions, "logs.read")
changed = true
}
return changed
}
// migrateActionsExecutePermission grants actions.execute to groups that
// previously could run actions via jobs.run. Returns true when settings changed.
func migrateActionsExecutePermission(payload *settings.Settings) bool {
changed := false
for index := range payload.Groups {
group := &payload.Groups[index]
hasActionsExecute := false
hasJobsRun := false
for _, permission := range group.Permissions {
switch permission {
case "actions.execute":
hasActionsExecute = true
case "jobs.run":
hasJobsRun = true
}
}
if hasActionsExecute || !hasJobsRun {
continue
}
group.Permissions = append(group.Permissions, "actions.execute")
changed = true
}
return changed
}
@@ -0,0 +1,95 @@
package api
import (
"bytes"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
func TestSetupStatusIncomplete(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
request := httptest.NewRequest(http.MethodGet, "/api/v1/setup/status", nil)
recorder := httptest.NewRecorder()
NewRouter(configDir).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status %d", recorder.Code)
}
var payload setupStatusResponse
if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil {
t.Fatal(err)
}
if payload.Completed {
t.Fatal("expected incomplete")
}
}
func TestSetupCompleteAndLogin(t *testing.T) {
configDir := t.TempDir()
keyBytes := make([]byte, 32)
for index := range keyBytes {
keyBytes[index] = byte(index + 3)
}
t.Setenv(settings.ConfigKeyEnvVar, base64.StdEncoding.EncodeToString(keyBytes))
router := NewRouter(configDir)
body := map[string]any{
"username": "Admin",
"password": "apple banana cherry date elderberry",
"totp_secret": "",
"totp_confirmed": false,
"network": map[string]any{
"listen_address": "0.0.0.0",
"public_hostname": "",
"access_mode": "open",
"rules": []string{},
},
"security": settings.DefaultSecuritySettings(),
}
encoded, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
completeReq := httptest.NewRequest(http.MethodPost, "/api/v1/setup/complete", bytes.NewReader(encoded))
completeRec := httptest.NewRecorder()
router.ServeHTTP(completeRec, completeReq)
if completeRec.Code != http.StatusOK {
t.Fatalf("complete status %d body %s", completeRec.Code, completeRec.Body.String())
}
loginBody := []byte(`{"username":"Admin","password":"apple banana cherry date elderberry"}`)
loginReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody))
loginRec := httptest.NewRecorder()
router.ServeHTTP(loginRec, loginReq)
if loginRec.Code != http.StatusOK {
t.Fatalf("login status %d body %s", loginRec.Code, loginRec.Body.String())
}
var sessionCookie *http.Cookie
for _, cookie := range loginRec.Result().Cookies() {
if cookie.Name == "__Host-ClusterCanvas-Session" {
sessionCookie = cookie
break
}
}
if sessionCookie == nil {
t.Fatal("missing session cookie")
}
if !sessionCookie.Secure || !sessionCookie.HttpOnly || sessionCookie.Path != "/" {
t.Fatalf("cookie flags incorrect: %#v", sessionCookie)
}
meReq := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
meReq.AddCookie(sessionCookie)
meRec := httptest.NewRecorder()
router.ServeHTTP(meRec, meReq)
if meRec.Code != http.StatusOK {
t.Fatalf("me status %d body %s", meRec.Code, meRec.Body.String())
}
}
+78
View File
@@ -0,0 +1,78 @@
package api
import (
"encoding/base64"
"net/http"
"net/http/httptest"
"testing"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
func seedCompletedSetup(t *testing.T, configDir string) *http.Cookie {
t.Helper()
keyBytes := make([]byte, 32)
for index := range keyBytes {
keyBytes[index] = byte(index + 1)
}
t.Setenv(settings.ConfigKeyEnvVar, base64.StdEncoding.EncodeToString(keyBytes))
now := time.Now().UTC()
passwordHash, err := auth.HashPassword("correct horse battery staple extra")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
user := settings.UserCredential{
ID: "test-user-id",
Username: "Admin",
PasswordHash: passwordHash,
Enabled: true,
GroupNames: []string{settings.AdministratorsGroupName},
CreatedAt: now,
PasswordChangedAt: now,
}
if err := settings.SavePasswords(configDir, settings.PasswordStore{Users: []settings.UserCredential{user}}, keyBytes); err != nil {
t.Fatalf("SavePasswords: %v", err)
}
security := settings.DefaultSecuritySettings()
payload := settings.Settings{
SetupCompleted: true,
Groups: []settings.Group{
{
Name: settings.AdministratorsGroupName,
ScopeKind: settings.GroupScopeGroup,
ScopeName: "Admin Group",
Permissions: allPermissionList(),
},
},
Security: security,
Network: settings.DefaultNetworkSettings(),
}
if err := settings.SaveSettings(configDir, payload); err != nil {
t.Fatalf("SaveSettings: %v", err)
}
manager := auth.NewSessionManager(configDir, keyBytes)
recorder := httptest.NewRecorder()
if _, err := manager.CreateSession(recorder, user.ID, security); err != nil {
t.Fatalf("CreateSession: %v", err)
}
for _, cookie := range recorder.Result().Cookies() {
if cookie.Name == auth.SessionCookieName {
return cookie
}
}
t.Fatal("session cookie not set")
return nil
}
func withSession(request *http.Request, cookie *http.Cookie) *http.Request {
request.AddCookie(cookie)
return request
}
+425
View File
@@ -0,0 +1,425 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
type userPublic struct {
ID string `json:"id"`
Username string `json:"username"`
Enabled bool `json:"enabled"`
TOTPConfirmed bool `json:"totp_confirmed"`
GroupNames []string `json:"group_names"`
CreatedAt time.Time `json:"created_at"`
PasswordChangedAt time.Time `json:"password_changed_at"`
IsAdmin bool `json:"is_admin"`
CanDelete bool `json:"can_delete"`
}
type usersResponse struct {
Users []userPublic `json:"users"`
}
type createUserRequest struct {
Username string `json:"username"`
Password string `json:"password"`
GroupNames []string `json:"group_names"`
}
type patchUserRequest struct {
GroupNames *[]string `json:"group_names"`
Password *string `json:"password"`
DisableTOTP *bool `json:"disable_totp"`
}
type deleteUserRequest struct {
CurrentPassword string `json:"current_password"`
TOTPCode string `json:"totp_code"`
}
func (app *App) usersGetHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.requireKey(); err != nil {
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()})
return
}
store, err := settings.LoadPasswords(app.ConfigDir, app.Key)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, usersResponse{Users: toPublicUsers(store.Users)})
}
func (app *App) usersCreateHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.authorizeUsersWrite(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
}
var payload createUserRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
username := strings.TrimSpace(payload.Username)
if username == "" {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "username is required"})
return
}
if err := auth.ValidatePassword(payload.Password, username); 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
}
groupNames, err := validateGroupNames(payload.GroupNames, settingsPayload.Groups)
if err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
store, err := settings.LoadPasswords(app.ConfigDir, app.Key)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
for _, existing := range store.Users {
if strings.EqualFold(existing.Username, username) {
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "username already exists"})
return
}
}
hash, err := auth.HashPassword(payload.Password)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
userID, err := newUserID()
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
now := time.Now().UTC()
store.Users = append(store.Users, settings.UserCredential{
ID: userID,
Username: username,
PasswordHash: hash,
Enabled: true,
GroupNames: groupNames,
CreatedAt: now,
PasswordChangedAt: now,
})
if err := settings.SavePasswords(app.ConfigDir, store, app.Key); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
appendAuthAudit(
app.ConfigDir,
"user_create",
settings.AuthAuditOutcomeSuccess,
settings.AuthAuditCategoryUser,
actorUsernameFromRequest(request),
username,
"",
)
writeJSON(writer, http.StatusOK, usersResponse{Users: toPublicUsers(store.Users)})
}
func (app *App) usersPatchHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.authorizeUsersWrite(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 err := app.requireGraceReauth(request); err != nil {
if err.Error() == reauthRequiredMessage {
writeReauthRequired(writer)
return
}
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: err.Error()})
return
}
userID := strings.TrimSpace(request.URL.Query().Get("id"))
if userID == "" {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "missing user id"})
return
}
var payload patchUserRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
if payload.GroupNames == nil && payload.Password == nil && payload.DisableTOTP == nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "no fields to update"})
return
}
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
store, err := settings.LoadPasswords(app.ConfigDir, app.Key)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
index := -1
for i := range store.Users {
if store.Users[i].ID == userID {
index = i
break
}
}
if index < 0 {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "user not found"})
return
}
target := &store.Users[index]
passwordChanged := false
if payload.GroupNames != nil {
groupNames, err := validateGroupNames(*payload.GroupNames, settingsPayload.Groups)
if err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
wasAdmin := userIsAdministrator(*target)
wouldBeAdmin := false
for _, name := range groupNames {
if name == settings.AdministratorsGroupName {
wouldBeAdmin = true
break
}
}
if wasAdmin && !wouldBeAdmin && countAdministrators(store.Users) <= 1 {
writeJSON(writer, http.StatusConflict, apiErrorResponse{
Error: "cannot remove the last administrator from the Administrators group",
})
return
}
target.GroupNames = groupNames
}
if payload.Password != nil {
if err := auth.ValidatePassword(*payload.Password, target.Username); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
hash, err := auth.HashPassword(*payload.Password)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
target.PasswordHash = hash
target.PasswordChangedAt = time.Now().UTC()
passwordChanged = true
}
if payload.DisableTOTP != nil && *payload.DisableTOTP {
target.TOTPSecret = ""
target.TOTPConfirmed = false
}
if err := settings.SavePasswords(app.ConfigDir, store, app.Key); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
if passwordChanged && app.Sessions != nil {
_ = 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)})
}
func (app *App) usersDeleteHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.authorizeUsersWrite(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
}
userID := strings.TrimSpace(request.URL.Query().Get("id"))
if userID == "" {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "missing user id"})
return
}
var payload deleteUserRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
if err := app.requireInlineReauth(request, payload.CurrentPassword, payload.TOTPCode); err != nil {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: err.Error()})
return
}
store, err := settings.LoadPasswords(app.ConfigDir, app.Key)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
adminCount := countAdministrators(store.Users)
remaining := make([]settings.UserCredential, 0, len(store.Users))
found := false
deletedUsername := ""
for _, user := range store.Users {
if user.ID != userID {
remaining = append(remaining, user)
continue
}
found = true
deletedUsername = user.Username
if userIsAdministrator(user) && adminCount <= 1 {
writeJSON(writer, http.StatusConflict, apiErrorResponse{
Error: "cannot delete the last administrator",
})
return
}
}
if !found {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "user not found"})
return
}
store.Users = remaining
if err := settings.SavePasswords(app.ConfigDir, store, app.Key); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
if app.Sessions != nil {
_ = 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)})
}
func validateGroupNames(names []string, groups []settings.Group) ([]string, error) {
if names == nil {
return []string{}, nil
}
known := make(map[string]struct{}, len(groups))
for _, group := range groups {
known[group.Name] = struct{}{}
}
result := make([]string, 0, len(names))
seen := make(map[string]struct{}, len(names))
for _, name := range names {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
continue
}
if _, ok := known[trimmed]; !ok {
return nil, fmt.Errorf("unknown group: %s", trimmed)
}
if _, ok := seen[trimmed]; ok {
continue
}
seen[trimmed] = struct{}{}
result = append(result, trimmed)
}
return result, nil
}
func toPublicUsers(users []settings.UserCredential) []userPublic {
adminCount := countAdministrators(users)
result := make([]userPublic, 0, len(users))
for _, user := range users {
isAdmin := userIsAdministrator(user)
groupNames := user.GroupNames
if groupNames == nil {
groupNames = []string{}
}
result = append(result, userPublic{
ID: user.ID,
Username: user.Username,
Enabled: user.Enabled,
TOTPConfirmed: user.TOTPConfirmed,
GroupNames: groupNames,
CreatedAt: user.CreatedAt,
PasswordChangedAt: user.PasswordChangedAt,
IsAdmin: isAdmin,
CanDelete: !(isAdmin && adminCount <= 1),
})
}
return result
}
func userIsAdministrator(user settings.UserCredential) bool {
for _, groupName := range user.GroupNames {
if groupName == settings.AdministratorsGroupName {
return true
}
}
return false
}
func countAdministrators(users []settings.UserCredential) int {
count := 0
for _, user := range users {
if userIsAdministrator(user) {
count++
}
}
return count
}
+464
View File
@@ -0,0 +1,464 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
type usersResponseTest struct {
Users []userPublic `json:"users"`
}
func TestUsersGetListsSanitizedFields(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/users", 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())
}
rawBody := recorder.Body.Bytes()
var payload usersResponseTest
if err := json.Unmarshal(rawBody, &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(payload.Users) != 1 {
t.Fatalf("expected 1 user, got %d", len(payload.Users))
}
user := payload.Users[0]
if user.Username != "Admin" {
t.Fatalf("expected username Admin, got %q", user.Username)
}
if !user.IsAdmin {
t.Fatal("expected is_admin true")
}
if user.CanDelete {
t.Fatal("expected can_delete false for sole admin")
}
if user.ID == "" || user.Username == "" {
t.Fatal("expected id and username")
}
raw := string(rawBody)
if strings.Contains(raw, "password_hash") || strings.Contains(raw, "totp_secret") {
t.Fatalf("response leaked secrets: %s", raw)
}
}
func TestUsersDeleteRejectsLastAdministrator(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
request := withSession(
httptest.NewRequest(
http.MethodDelete,
"/api/v1/users?id=test-user-id",
strings.NewReader(`{"current_password":"correct horse battery staple extra"}`),
),
cookie,
)
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusConflict {
t.Fatalf("expected status %d, got %d body=%s", http.StatusConflict, recorder.Code, recorder.Body.String())
}
var payload apiErrorResponseTest
if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Error != "cannot delete the last administrator" {
t.Fatalf("unexpected error: %q", payload.Error)
}
}
func TestUsersDeleteAllowsNonAdminAndSecondAdmin(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
key, err := settings.KeyFromEnv()
if err != nil {
t.Fatalf("KeyFromEnv: %v", err)
}
store, err := settings.LoadPasswords(configDir, key)
if err != nil {
t.Fatalf("LoadPasswords: %v", err)
}
now := time.Now().UTC()
passwordHash, err := auth.HashPassword("correct horse battery staple extra")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
store.Users = append(store.Users,
settings.UserCredential{
ID: "operator-id",
Username: "operator",
PasswordHash: passwordHash,
Enabled: true,
GroupNames: []string{"Operators"},
CreatedAt: now,
PasswordChangedAt: now,
},
settings.UserCredential{
ID: "admin-two-id",
Username: "AdminTwo",
PasswordHash: passwordHash,
Enabled: true,
GroupNames: []string{settings.AdministratorsGroupName},
CreatedAt: now,
PasswordChangedAt: now,
},
)
if err := settings.SavePasswords(configDir, store, key); err != nil {
t.Fatalf("SavePasswords: %v", err)
}
router := NewRouter(configDir)
reauthBody := `{"current_password":"correct horse battery staple extra"}`
deleteOperator := withSession(
httptest.NewRequest(http.MethodDelete, "/api/v1/users?id=operator-id", strings.NewReader(reauthBody)),
cookie,
)
deleteOperator.Header.Set("Content-Type", "application/json")
operatorRec := httptest.NewRecorder()
router.ServeHTTP(operatorRec, deleteOperator)
if operatorRec.Code != http.StatusOK {
t.Fatalf("delete operator: expected %d, got %d body=%s", http.StatusOK, operatorRec.Code, operatorRec.Body.String())
}
deleteSecondAdmin := withSession(
httptest.NewRequest(http.MethodDelete, "/api/v1/users?id=admin-two-id", strings.NewReader(reauthBody)),
cookie,
)
deleteSecondAdmin.Header.Set("Content-Type", "application/json")
adminRec := httptest.NewRecorder()
router.ServeHTTP(adminRec, deleteSecondAdmin)
if adminRec.Code != http.StatusOK {
t.Fatalf("delete second admin: expected %d, got %d body=%s", http.StatusOK, adminRec.Code, adminRec.Body.String())
}
var payload usersResponseTest
if err := json.NewDecoder(adminRec.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(payload.Users) != 1 {
t.Fatalf("expected 1 remaining user, got %d", len(payload.Users))
}
if payload.Users[0].ID != "test-user-id" {
t.Fatalf("expected sole remaining admin id, got %q", payload.Users[0].ID)
}
if payload.Users[0].CanDelete {
t.Fatal("expected remaining sole admin can_delete false")
}
}
func TestUsersDeleteMissingIDFails(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
request := withSession(httptest.NewRequest(http.MethodDelete, "/api/v1/users", nil), cookie)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, recorder.Code)
}
}
func TestUsersDeleteUnknownUserNotFound(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
request := withSession(
httptest.NewRequest(
http.MethodDelete,
"/api/v1/users?id=missing",
strings.NewReader(`{"current_password":"correct horse battery staple extra"}`),
),
cookie,
)
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusNotFound {
t.Fatalf("expected status %d, got %d body=%s", http.StatusNotFound, recorder.Code, recorder.Body.String())
}
}
func TestUsersCreateAndPatch(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
key, err := settings.KeyFromEnv()
if err != nil {
t.Fatalf("KeyFromEnv: %v", err)
}
payload, err := settings.LoadSettings(configDir)
if err != nil {
t.Fatalf("LoadSettings: %v", err)
}
payload.Groups = append(payload.Groups, settings.Group{
Name: "Operators",
ScopeKind: settings.GroupScopeGroup,
ScopeName: "Ops",
Permissions: []string{"nodes.read"},
})
if err := settings.SaveSettings(configDir, payload); err != nil {
t.Fatalf("SaveSettings: %v", err)
}
router := NewRouter(configDir)
createReq := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/users",
strings.NewReader(`{"username":"alice","password":"correct horse battery staple extra","group_names":["Operators"]}`),
),
cookie,
)
createReq.Header.Set("Content-Type", "application/json")
createRec := httptest.NewRecorder()
router.ServeHTTP(createRec, createReq)
if createRec.Code != http.StatusOK {
t.Fatalf("create: expected %d, got %d body=%s", http.StatusOK, createRec.Code, createRec.Body.String())
}
var created usersResponseTest
if err := json.NewDecoder(createRec.Body).Decode(&created); err != nil {
t.Fatalf("decode create: %v", err)
}
var aliceID string
for _, user := range created.Users {
if user.Username == "alice" {
aliceID = user.ID
if len(user.GroupNames) != 1 || user.GroupNames[0] != "Operators" {
t.Fatalf("unexpected groups: %#v", user.GroupNames)
}
}
}
if aliceID == "" {
t.Fatal("alice not found after create")
}
patchReq := withSession(
httptest.NewRequest(
http.MethodPatch,
"/api/v1/users?id="+aliceID,
strings.NewReader(`{"disable_totp":true,"password":"another strong passphrase here"}`),
),
cookie,
)
patchReq.Header.Set("Content-Type", "application/json")
patchRec := httptest.NewRecorder()
router.ServeHTTP(patchRec, patchReq)
if patchRec.Code != http.StatusOK {
t.Fatalf("patch: expected %d, got %d body=%s", http.StatusOK, patchRec.Code, patchRec.Body.String())
}
store, err := settings.LoadPasswords(configDir, key)
if err != nil {
t.Fatalf("LoadPasswords: %v", err)
}
for _, user := range store.Users {
if user.ID != aliceID {
continue
}
if user.TOTPConfirmed || user.TOTPSecret != "" {
t.Fatal("expected TOTP cleared")
}
ok, err := auth.VerifyPassword("another strong passphrase here", user.PasswordHash)
if err != nil || !ok {
t.Fatal("expected password updated")
}
}
}
func TestUsersWriteRequiresPermission(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
key, err := settings.KeyFromEnv()
if err != nil {
t.Fatalf("KeyFromEnv: %v", err)
}
payload, err := settings.LoadSettings(configDir)
if err != nil {
t.Fatalf("LoadSettings: %v", err)
}
// Strip users.manage from Administrators
for index := range payload.Groups {
if payload.Groups[index].Name != settings.AdministratorsGroupName {
continue
}
filtered := make([]string, 0)
for _, permission := range payload.Groups[index].Permissions {
if permission == "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)
}
_ = key
router := NewRouter(configDir)
request := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/users",
strings.NewReader(`{"username":"bob","password":"correct horse battery staple extra"}`),
),
cookie,
)
request.Header.Set("Content-Type", "application/json")
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 TestUsersPatchRequiresGraceReauth(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
key, err := settings.KeyFromEnv()
if err != nil {
t.Fatalf("KeyFromEnv: %v", err)
}
payload, err := settings.LoadSettings(configDir)
if err != nil {
t.Fatalf("LoadSettings: %v", err)
}
payload.Security.ReauthSensitiveActions = true
payload.Security.ReauthGraceMinutes = 0
if err := settings.SaveSettings(configDir, payload); err != nil {
t.Fatalf("SaveSettings: %v", err)
}
// Age the session's LastReauthAt so grace (0) always fails unless MarkReauth just ran.
store, err := settings.LoadSessionsOrEmpty(configDir, key)
if err != nil {
t.Fatalf("LoadSessions: %v", err)
}
for index := range store.Sessions {
store.Sessions[index].LastReauthAt = time.Now().UTC().Add(-time.Minute)
}
if err := settings.SaveSessions(configDir, store, key); err != nil {
t.Fatalf("SaveSessions: %v", err)
}
router := NewRouter(configDir)
patchReq := withSession(
httptest.NewRequest(
http.MethodPatch,
"/api/v1/users?id=test-user-id",
strings.NewReader(`{"password":"another strong passphrase here"}`),
),
cookie,
)
patchReq.Header.Set("Content-Type", "application/json")
patchRec := httptest.NewRecorder()
router.ServeHTTP(patchRec, patchReq)
if patchRec.Code != http.StatusUnauthorized {
t.Fatalf("expected reauth_required status %d, got %d body=%s", http.StatusUnauthorized, patchRec.Code, patchRec.Body.String())
}
var errPayload apiErrorResponseTest
if err := json.NewDecoder(patchRec.Body).Decode(&errPayload); err != nil {
t.Fatalf("decode: %v", err)
}
if errPayload.Error != "reauth_required" {
t.Fatalf("expected reauth_required, got %q", errPayload.Error)
}
reauthReq := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/auth/reauth",
strings.NewReader(`{"password":"correct horse battery staple extra"}`),
),
cookie,
)
reauthReq.Header.Set("Content-Type", "application/json")
reauthRec := httptest.NewRecorder()
router.ServeHTTP(reauthRec, reauthReq)
if reauthRec.Code != http.StatusOK {
t.Fatalf("reauth: expected %d, got %d body=%s", http.StatusOK, reauthRec.Code, reauthRec.Body.String())
}
// Refresh cookie if rotated
for _, setCookie := range reauthRec.Result().Cookies() {
if setCookie.Name == auth.SessionCookieName {
cookie = setCookie
}
}
retryReq := withSession(
httptest.NewRequest(
http.MethodPatch,
"/api/v1/users?id=test-user-id",
strings.NewReader(`{"password":"another strong passphrase here"}`),
),
cookie,
)
retryReq.Header.Set("Content-Type", "application/json")
retryRec := httptest.NewRecorder()
router.ServeHTTP(retryRec, retryReq)
if retryRec.Code != http.StatusOK {
t.Fatalf("retry patch: expected %d, got %d body=%s", http.StatusOK, retryRec.Code, retryRec.Body.String())
}
}
func TestMePasswordChange(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
request := withSession(
httptest.NewRequest(
http.MethodPut,
"/api/v1/me/password",
strings.NewReader(`{"current_password":"correct horse battery staple extra","new_password":"fresh strong passphrase words"}`),
),
cookie,
)
request.Header.Set("Content-Type", "application/json")
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())
}
// Session should be invalidated
meReq := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil), cookie)
meRec := httptest.NewRecorder()
router.ServeHTTP(meRec, meReq)
if meRec.Code != http.StatusUnauthorized {
t.Fatalf("expected session invalidated, got %d", meRec.Code)
}
}
+16
View File
@@ -0,0 +1,16 @@
package auth
import (
"crypto/rand"
"encoding/base64"
"fmt"
)
// NewRandomID returns a URL-safe high-entropy identifier.
func NewRandomID() (string, error) {
buffer := make([]byte, 16)
if _, err := rand.Read(buffer); err != nil {
return "", fmt.Errorf("random id: %w", err)
}
return base64.RawURLEncoding.EncodeToString(buffer), nil
}
+135
View File
@@ -0,0 +1,135 @@
package auth
import (
"fmt"
"net"
"regexp"
"strings"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
var hostnamePattern = regexp.MustCompile(`^(?i)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$|^localhost$`)
// ValidateHostname checks a public hostname (empty is allowed for local/dev).
func ValidateHostname(hostname string) error {
trimmed := strings.TrimSpace(hostname)
if trimmed == "" {
return nil
}
if strings.Contains(trimmed, "/") || strings.Contains(trimmed, ":") || strings.Contains(trimmed, " ") {
return fmt.Errorf("public_hostname must be a bare DNS name without scheme, port, or path")
}
if !hostnamePattern.MatchString(trimmed) {
return fmt.Errorf("public_hostname is not a valid DNS hostname")
}
return nil
}
// HostMatches reports whether requestHost matches the configured public hostname.
// An empty / whitespace public hostname means any host is allowed (local/dev).
func HostMatches(requestHost string, publicHostname string) bool {
wanted := strings.TrimSpace(publicHostname)
if wanted == "" {
return true
}
host := strings.TrimSpace(requestHost)
if parsedHost, _, err := net.SplitHostPort(host); err == nil {
host = parsedHost
}
return strings.EqualFold(host, wanted)
}
// IPAllowed evaluates whitelist/blacklist rules against clientIP.
func IPAllowed(clientIP string, network settings.NetworkSettings) (bool, string) {
network = settings.EffectiveNetwork(network)
parsedIP := net.ParseIP(clientIP)
if parsedIP == nil {
return false, "unable to parse client IP"
}
switch network.AccessMode {
case settings.AccessModeOpen, "":
return true, ""
case settings.AccessModeWhitelist:
if matchesAnyRule(parsedIP, network.Rules) {
return true, ""
}
return false, "client IP is not on the whitelist"
case settings.AccessModeBlacklist:
if matchesAnyRule(parsedIP, network.Rules) {
return false, "client IP matches a blacklist rule"
}
return true, ""
default:
return false, "invalid access mode"
}
}
// WouldLockOut estimates whether rules would block clientIP.
func WouldLockOut(clientIP string, network settings.NetworkSettings) (bool, string) {
allowed, reason := IPAllowed(clientIP, network)
if allowed {
return false, ""
}
return true, reason
}
func matchesAnyRule(ip net.IP, rules []string) bool {
for _, rule := range rules {
trimmed := strings.TrimSpace(rule)
if trimmed == "" {
continue
}
if strings.Contains(trimmed, "/") {
_, network, err := net.ParseCIDR(trimmed)
if err != nil {
continue
}
if network.Contains(ip) {
return true
}
continue
}
ruleIP := net.ParseIP(trimmed)
if ruleIP != nil && ruleIP.Equal(ip) {
return true
}
}
return false
}
// ValidateListenAddress checks a bind address (IP or hostname).
func ValidateListenAddress(address string) error {
trimmed := strings.TrimSpace(address)
if trimmed == "" {
return fmt.Errorf("listen_address is required")
}
if trimmed == "0.0.0.0" || trimmed == "::" || trimmed == "localhost" {
return nil
}
if net.ParseIP(trimmed) != nil {
return nil
}
return fmt.Errorf("listen_address must be an IP address or 0.0.0.0")
}
// ValidateAccessRules validates CIDR/IP entries.
func ValidateAccessRules(rules []string) error {
for _, rule := range rules {
trimmed := strings.TrimSpace(rule)
if trimmed == "" {
continue
}
if strings.Contains(trimmed, "/") {
if _, _, err := net.ParseCIDR(trimmed); err != nil {
return fmt.Errorf("invalid CIDR rule %q", trimmed)
}
continue
}
if net.ParseIP(trimmed) == nil {
return fmt.Errorf("invalid IP rule %q", trimmed)
}
}
return nil
}
+128
View File
@@ -0,0 +1,128 @@
package auth
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"strings"
"unicode"
"golang.org/x/crypto/argon2"
)
const (
argon2Time = 3
argon2Memory = 64 * 1024
argon2Threads = 4
argon2KeyLen = 32
argon2SaltLen = 16
minPasswordLength = 14
minPassphraseWords = 5
maxPasswordLength = 256
)
var (
ErrPasswordTooLong = errors.New("password must be at most 256 characters")
ErrPasswordTooWeak = errors.New("password must be at least 14 characters or at least 5 words")
ErrPasswordMatchesUser = errors.New("password cannot match the login name")
ErrPasswordCommon = errors.New("password is too common")
ErrPasswordNotPrintable = errors.New("password may only contain printable characters")
)
// ValidatePassword checks password policy rules.
func ValidatePassword(password string, username string) error {
if len(password) > maxPasswordLength {
return ErrPasswordTooLong
}
for _, runeValue := range password {
if !unicode.IsPrint(runeValue) {
return ErrPasswordNotPrintable
}
}
if strings.EqualFold(strings.TrimSpace(password), strings.TrimSpace(username)) {
return ErrPasswordMatchesUser
}
if isCommonPassword(password) {
return ErrPasswordCommon
}
wordCount := countWords(password)
if len(password) >= minPasswordLength || wordCount >= minPassphraseWords {
return nil
}
return ErrPasswordTooWeak
}
func countWords(password string) int {
fields := strings.Fields(password)
return len(fields)
}
// HashPassword returns a PHC-formatted argon2id hash string.
func HashPassword(password string) (string, error) {
salt := make([]byte, argon2SaltLen)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("generate salt: %w", err)
}
hash := argon2.IDKey(
[]byte(password),
salt,
argon2Time,
argon2Memory,
argon2Threads,
argon2KeyLen,
)
return fmt.Sprintf(
"$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version,
argon2Memory,
argon2Time,
argon2Threads,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(hash),
), nil
}
// VerifyPassword compares password against a PHC argon2id hash.
func VerifyPassword(password string, encodedHash string) (bool, error) {
parts := strings.Split(encodedHash, "$")
if len(parts) != 6 || parts[1] != "argon2id" {
return false, errors.New("unsupported password hash format")
}
var version int
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
return false, errors.New("invalid hash version")
}
var memory uint32
var timeCost uint32
var threads uint8
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &timeCost, &threads); err != nil {
return false, errors.New("invalid hash parameters")
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false, errors.New("invalid hash salt")
}
expectedHash, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return false, errors.New("invalid hash digest")
}
computed := argon2.IDKey(
[]byte(password),
salt,
timeCost,
memory,
threads,
uint32(len(expectedHash)),
)
return subtle.ConstantTimeCompare(computed, expectedHash) == 1, nil
}
+70
View File
@@ -0,0 +1,70 @@
package auth
import (
"testing"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
func TestValidatePasswordAcceptsLongRandom(t *testing.T) {
t.Parallel()
if err := ValidatePassword("Tr0ub4dor&3-extra!", "Admin"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestValidatePasswordAcceptsPassphrase(t *testing.T) {
t.Parallel()
if err := ValidatePassword("apple banana cherry date elderberry", "Admin"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestValidatePasswordRejectsShort(t *testing.T) {
t.Parallel()
if err := ValidatePassword("short-pass", "Admin"); err == nil {
t.Fatal("expected error")
}
}
func TestValidatePasswordRejectsUsername(t *testing.T) {
t.Parallel()
if err := ValidatePassword("AdminAdminAdmin!", "AdminAdminAdmin!"); err == nil {
t.Fatal("expected error")
}
}
func TestHashAndVerifyPassword(t *testing.T) {
t.Parallel()
hash, err := HashPassword("apple banana cherry date elderberry")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
ok, err := VerifyPassword("apple banana cherry date elderberry", hash)
if err != nil || !ok {
t.Fatalf("VerifyPassword failed: ok=%v err=%v", ok, err)
}
ok, err = VerifyPassword("wrong password here!!", hash)
if err != nil {
t.Fatalf("VerifyPassword error: %v", err)
}
if ok {
t.Fatal("expected mismatch")
}
}
func TestWouldLockOutWhitelist(t *testing.T) {
t.Parallel()
network := settings.NetworkSettings{
AccessMode: settings.AccessModeWhitelist,
Rules: []string{"10.0.0.0/8"},
}
locked, _ := WouldLockOut("192.168.1.5", network)
if !locked {
t.Fatal("expected lock out")
}
locked, _ = WouldLockOut("10.1.2.3", network)
if locked {
t.Fatal("expected allow")
}
}
+89
View File
@@ -0,0 +1,89 @@
package auth
import (
"fmt"
"net"
"os/exec"
"strings"
"time"
)
const (
PingMethodICMP = "icmp"
PingMethodTCP = "tcp"
defaultPingCount = "1"
defaultPingWaitSec = "2"
defaultTCPTimeout = 3 * time.Second
)
// PingResult is the outcome of a reachability probe.
type PingResult struct {
OK bool
Method string
Error error
}
type pingCommandFunc func(hostIP string) error
type tcpDialFunc func(address string, timeout time.Duration) error
var (
runICMPPing = defaultRunICMPPing
dialTCP = defaultDialTCP
)
// PingHost probes host reachability: ICMP first, then TCP :22 when ICMP is
// unavailable or the host does not respond to ICMP.
func PingHost(hostIP string) PingResult {
hostIP = strings.TrimSpace(hostIP)
if hostIP == "" {
return PingResult{OK: false, Error: fmt.Errorf("host IP is required")}
}
icmpErr := runICMPPing(hostIP)
if icmpErr == nil {
return PingResult{OK: true, Method: PingMethodICMP}
}
address := net.JoinHostPort(hostIP, defaultSSHPort)
tcpErr := dialTCP(address, defaultTCPTimeout)
if tcpErr == nil {
return PingResult{OK: true, Method: PingMethodTCP}
}
return PingResult{
OK: false,
Method: PingMethodTCP,
Error: fmt.Errorf(
"icmp: %v; tcp %s: %w",
icmpErr,
address,
tcpErr,
),
}
}
func defaultRunICMPPing(hostIP string) error {
pingPath, err := exec.LookPath("ping")
if err != nil {
return fmt.Errorf("ping unavailable: %w", err)
}
cmd := exec.Command(pingPath, "-c", defaultPingCount, "-W", defaultPingWaitSec, hostIP)
output, runErr := cmd.CombinedOutput()
if runErr == nil {
return nil
}
combined := strings.TrimSpace(string(output) + " " + runErr.Error())
return fmt.Errorf("%s", strings.TrimSpace(combined))
}
func defaultDialTCP(address string, timeout time.Duration) error {
conn, err := net.DialTimeout("tcp", address, timeout)
if err != nil {
return err
}
_ = conn.Close()
return nil
}
+96
View File
@@ -0,0 +1,96 @@
package auth
import (
"errors"
"testing"
"time"
)
func TestPingHostUsesICMPWhenAvailable(t *testing.T) {
originalPing := runICMPPing
originalDial := dialTCP
t.Cleanup(func() {
runICMPPing = originalPing
dialTCP = originalDial
})
runICMPPing = func(hostIP string) error {
if hostIP != "10.0.0.1" {
t.Fatalf("host = %q", hostIP)
}
return nil
}
dialTCP = func(address string, timeout time.Duration) error {
t.Fatal("tcp should not be used when icmp succeeds")
return nil
}
result := PingHost("10.0.0.1")
if !result.OK {
t.Fatalf("expected ok, got %#v", result)
}
if result.Method != PingMethodICMP {
t.Fatalf("method = %q", result.Method)
}
}
func TestPingHostFallsBackToTCPWhenICMPFails(t *testing.T) {
originalPing := runICMPPing
originalDial := dialTCP
t.Cleanup(func() {
runICMPPing = originalPing
dialTCP = originalDial
})
runICMPPing = func(hostIP string) error {
return errors.New("ping unavailable: executable file not found")
}
dialTCP = func(address string, timeout time.Duration) error {
if address != "10.0.0.2:22" {
t.Fatalf("address = %q", address)
}
return nil
}
result := PingHost("10.0.0.2")
if !result.OK {
t.Fatalf("expected ok, got %#v", result)
}
if result.Method != PingMethodTCP {
t.Fatalf("method = %q", result.Method)
}
}
func TestPingHostFailsWhenBothFail(t *testing.T) {
originalPing := runICMPPing
originalDial := dialTCP
t.Cleanup(func() {
runICMPPing = originalPing
dialTCP = originalDial
})
runICMPPing = func(hostIP string) error {
return errors.New("100% packet loss")
}
dialTCP = func(address string, timeout time.Duration) error {
return errors.New("connection refused")
}
result := PingHost("10.0.0.3")
if result.OK {
t.Fatal("expected failure")
}
if result.Error == nil {
t.Fatal("expected error")
}
if result.Method != PingMethodTCP {
t.Fatalf("method = %q", result.Method)
}
}
func TestPingHostRequiresHostIP(t *testing.T) {
result := PingHost(" ")
if result.OK || result.Error == nil {
t.Fatalf("expected failure, got %#v", result)
}
}
+53
View File
@@ -0,0 +1,53 @@
package auth
import (
"regexp"
"strings"
)
var placeholderPattern = regexp.MustCompile(`\{\{\s*([a-zA-Z0-9_.]+)\s*\}\}`)
// PlaceholderContext holds values used to expand action body placeholders.
type PlaceholderContext struct {
Host string
IP string
Username string
Env map[string]string
Secrets map[string]string
}
// ExpandPlaceholders replaces {{cc.host}}, {{node.ip}}, {{env.NAME}}, etc.
// Unknown or unresolved secrets expand to empty strings.
func ExpandPlaceholders(body string, ctx PlaceholderContext) string {
return placeholderPattern.ReplaceAllStringFunc(body, func(match string) string {
submatches := placeholderPattern.FindStringSubmatch(match)
if len(submatches) < 2 {
return match
}
key := strings.TrimSpace(submatches[1])
switch key {
case "cc.host", "node.host":
return ctx.Host
case "cc.ip", "node.ip":
return ctx.IP
case "node.username":
return ctx.Username
default:
if strings.HasPrefix(key, "env.") {
name := strings.TrimPrefix(key, "env.")
if ctx.Env != nil {
return ctx.Env[name]
}
return ""
}
if strings.HasPrefix(key, "secret.") {
name := strings.TrimPrefix(key, "secret.")
if ctx.Secrets != nil {
return ctx.Secrets[name]
}
return ""
}
return ""
}
})
}
@@ -0,0 +1,18 @@
package auth
import "testing"
func TestExpandPlaceholders(t *testing.T) {
body := "ping {{node.ip}} as {{node.username}} host={{cc.host}} env={{env.FOO}} secret={{secret.TOKEN}} unknown={{nope}}"
got := ExpandPlaceholders(body, PlaceholderContext{
Host: "web-1",
IP: "10.0.0.5",
Username: "deploy",
Env: map[string]string{"FOO": "bar"},
Secrets: map[string]string{},
})
want := "ping 10.0.0.5 as deploy host=web-1 env=bar secret= unknown="
if got != want {
t.Fatalf("got %q want %q", got, want)
}
}
+101
View File
@@ -0,0 +1,101 @@
package auth
import (
"fmt"
"strings"
)
const MaxResolveCommandNames = 32
// ValidateCommandNames checks relative command basenames for resolve-commands.
func ValidateCommandNames(names []string) ([]string, error) {
if len(names) == 0 {
return nil, fmt.Errorf("names is required")
}
if len(names) > MaxResolveCommandNames {
return nil, fmt.Errorf("at most %d names allowed", MaxResolveCommandNames)
}
cleaned := make([]string, 0, len(names))
seen := make(map[string]struct{}, len(names))
for _, raw := range names {
name := strings.TrimSpace(raw)
if name == "" {
return nil, fmt.Errorf("command name must not be empty")
}
if strings.Contains(name, "/") {
return nil, fmt.Errorf("command name must be a basename without /")
}
if len(name) > 64 {
return nil, fmt.Errorf("command name too long")
}
for _, runeValue := range name {
if (runeValue >= 'a' && runeValue <= 'z') ||
(runeValue >= 'A' && runeValue <= 'Z') ||
(runeValue >= '0' && runeValue <= '9') ||
runeValue == '.' || runeValue == '_' || runeValue == '-' || runeValue == '+' {
continue
}
return nil, fmt.Errorf("command name contains invalid characters")
}
if _, exists := seen[name]; exists {
continue
}
seen[name] = struct{}{}
cleaned = append(cleaned, name)
}
if len(cleaned) == 0 {
return nil, fmt.Errorf("names is required")
}
return cleaned, nil
}
// BuildResolveCommandsRemote builds a shell snippet that prints name=path lines.
func BuildResolveCommandsRemote(names []string) string {
var builder strings.Builder
for _, name := range names {
quoted := shellQuote(name)
builder.WriteString("path=$(command -v -- ")
builder.WriteString(quoted)
builder.WriteString(" 2>/dev/null || true); printf '%s=%s\\n' ")
builder.WriteString(quoted)
builder.WriteString(" \"$path\"; ")
}
return builder.String()
}
// ParseResolveCommandsOutput parses name=path lines into found paths and missing names.
func ParseResolveCommandsOutput(names []string, stdout string) (paths map[string]string, missing []string) {
paths = make(map[string]string, len(names))
wanted := make(map[string]struct{}, len(names))
for _, name := range names {
wanted[name] = struct{}{}
}
for _, line := range strings.Split(stdout, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
name, path, ok := strings.Cut(line, "=")
if !ok {
continue
}
name = strings.TrimSpace(name)
path = strings.TrimSpace(path)
if _, ok := wanted[name]; !ok {
continue
}
if path == "" || !strings.HasPrefix(path, "/") {
continue
}
paths[name] = path
}
for _, name := range names {
if _, ok := paths[name]; !ok {
missing = append(missing, name)
}
}
return paths, missing
}
@@ -0,0 +1,58 @@
package auth
import (
"fmt"
"strings"
"testing"
)
func TestValidateCommandNames(t *testing.T) {
cleaned, err := ValidateCommandNames([]string{" apt ", "systemctl", "apt"})
if err != nil {
t.Fatalf("valid names: %v", err)
}
if len(cleaned) != 2 || cleaned[0] != "apt" || cleaned[1] != "systemctl" {
t.Fatalf("cleaned = %#v", cleaned)
}
if _, err := ValidateCommandNames(nil); err == nil {
t.Fatal("expected error for empty names")
}
if _, err := ValidateCommandNames([]string{"/usr/bin/apt"}); err == nil {
t.Fatal("expected error for path")
}
if _, err := ValidateCommandNames([]string{"apt;rm"}); err == nil {
t.Fatal("expected error for metacharacters")
}
tooMany := make([]string, MaxResolveCommandNames+1)
for index := range tooMany {
tooMany[index] = fmt.Sprintf("cmd%d", index)
}
if _, err := ValidateCommandNames(tooMany); err == nil {
t.Fatal("expected error for too many names")
}
}
func TestParseResolveCommandsOutput(t *testing.T) {
paths, missing := ParseResolveCommandsOutput(
[]string{"apt", "missing", "systemctl"},
"apt=/usr/bin/apt\nmissing=\nsystemctl=/usr/bin/systemctl\n",
)
if paths["apt"] != "/usr/bin/apt" || paths["systemctl"] != "/usr/bin/systemctl" {
t.Fatalf("paths = %#v", paths)
}
if len(missing) != 1 || missing[0] != "missing" {
t.Fatalf("missing = %#v", missing)
}
}
func TestBuildResolveCommandsRemote(t *testing.T) {
remote := BuildResolveCommandsRemote([]string{"apt"})
if !strings.Contains(remote, "command -v -- 'apt'") {
t.Fatalf("remote = %q", remote)
}
if !strings.Contains(remote, "printf '%s=%s\\n' 'apt'") {
t.Fatalf("remote missing printf = %q", remote)
}
}
+294
View File
@@ -0,0 +1,294 @@
package auth
import (
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"net"
"net/http"
"strings"
"sync"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
const (
SessionCookieName = "__Host-ClusterCanvas-Session"
sessionIDBytes = 32
sessionRotateAfter = time.Hour
)
var (
ErrSessionNotFound = errors.New("session not found")
ErrSessionIdle = errors.New("session idle")
)
// SessionManager persists sessions in sessions.enc and sets hardened cookies.
type SessionManager struct {
configDir string
key []byte
mu sync.Mutex
}
func NewSessionManager(configDir string, key []byte) *SessionManager {
return &SessionManager{configDir: configDir, key: key}
}
// CreateSession invalidates prior sessions for the user, stores a new session, and sets the cookie.
func (manager *SessionManager) CreateSession(
writer http.ResponseWriter,
userID string,
security settings.SecuritySettings,
) (settings.SessionRecord, error) {
manager.mu.Lock()
defer manager.mu.Unlock()
store, err := settings.LoadSessionsOrEmpty(manager.configDir, manager.key)
if err != nil {
return settings.SessionRecord{}, err
}
now := time.Now().UTC()
lifetime := time.Duration(security.SessionLifetimeHours) * time.Hour
filtered := make([]settings.SessionRecord, 0, len(store.Sessions))
for _, session := range store.Sessions {
if session.UserID == userID {
continue
}
if session.ExpiresAt.Before(now) {
continue
}
filtered = append(filtered, session)
}
sessionID, err := newSessionID()
if err != nil {
return settings.SessionRecord{}, err
}
record := settings.SessionRecord{
ID: sessionID,
UserID: userID,
CreatedAt: now,
LastSeenAt: now,
LastReauthAt: now,
ExpiresAt: now.Add(lifetime),
}
filtered = append(filtered, record)
store.Sessions = filtered
if err := settings.SaveSessions(manager.configDir, store, manager.key); err != nil {
return settings.SessionRecord{}, err
}
setSessionCookie(writer, sessionID, int(lifetime.Seconds()))
return record, nil
}
// LookupValidSession finds a non-expired, non-idle session.
// When touchActivity is true, LastSeenAt is updated and the session may rotate.
// When touchActivity is false, the session is validated only (no LastSeenAt update,
// rotation, or sessions file write unless the session is being destroyed as idle/expired).
func (manager *SessionManager) LookupValidSession(
writer http.ResponseWriter,
request *http.Request,
security settings.SecuritySettings,
touchActivity bool,
) (settings.SessionRecord, error) {
cookie, err := request.Cookie(SessionCookieName)
if err != nil || strings.TrimSpace(cookie.Value) == "" {
return settings.SessionRecord{}, ErrSessionNotFound
}
manager.mu.Lock()
defer manager.mu.Unlock()
store, err := settings.LoadSessionsOrEmpty(manager.configDir, manager.key)
if err != nil {
return settings.SessionRecord{}, err
}
now := time.Now().UTC()
idleLimit := time.Duration(security.IdleTimeoutMinutes) * time.Minute
lifetime := time.Duration(security.SessionLifetimeHours) * time.Hour
var found *settings.SessionRecord
idleMatched := false
remaining := make([]settings.SessionRecord, 0, len(store.Sessions))
for index := range store.Sessions {
session := store.Sessions[index]
if session.ExpiresAt.Before(now) {
continue
}
if session.ID == cookie.Value {
if now.Sub(session.LastSeenAt) > idleLimit {
idleMatched = true
continue
}
copySession := session
found = &copySession
continue
}
remaining = append(remaining, session)
}
if found == nil {
store.Sessions = remaining
_ = settings.SaveSessions(manager.configDir, store, manager.key)
clearSessionCookie(writer)
if idleMatched {
return settings.SessionRecord{}, ErrSessionIdle
}
return settings.SessionRecord{}, ErrSessionNotFound
}
if !touchActivity {
return *found, nil
}
shouldRotate := now.Sub(found.CreatedAt) >= sessionRotateAfter ||
now.Sub(found.CreatedAt) >= lifetime/2
found.LastSeenAt = now
if shouldRotate {
newID, err := newSessionID()
if err != nil {
return settings.SessionRecord{}, err
}
found.ID = newID
found.CreatedAt = now
// LastReauthAt is preserved across rotation.
setSessionCookie(writer, newID, int(found.ExpiresAt.Sub(now).Seconds()))
}
remaining = append(remaining, *found)
store.Sessions = remaining
if err := settings.SaveSessions(manager.configDir, store, manager.key); err != nil {
return settings.SessionRecord{}, err
}
return *found, nil
}
// MarkReauth updates LastReauthAt for the session identified by sessionID.
func (manager *SessionManager) MarkReauth(sessionID string) error {
manager.mu.Lock()
defer manager.mu.Unlock()
store, err := settings.LoadSessionsOrEmpty(manager.configDir, manager.key)
if err != nil {
return err
}
now := time.Now().UTC()
found := false
for index := range store.Sessions {
if store.Sessions[index].ID != sessionID {
continue
}
store.Sessions[index].LastReauthAt = now
found = true
break
}
if !found {
return ErrSessionNotFound
}
return settings.SaveSessions(manager.configDir, store, manager.key)
}
// InvalidateUserSessions removes all persisted sessions for the given user ID.
func (manager *SessionManager) InvalidateUserSessions(userID string) error {
manager.mu.Lock()
defer manager.mu.Unlock()
store, err := settings.LoadSessionsOrEmpty(manager.configDir, manager.key)
if err != nil {
return err
}
filtered := make([]settings.SessionRecord, 0, len(store.Sessions))
for _, session := range store.Sessions {
if session.UserID == userID {
continue
}
filtered = append(filtered, session)
}
store.Sessions = filtered
return settings.SaveSessions(manager.configDir, store, manager.key)
}
// DestroySession removes the presented session and clears the cookie.
func (manager *SessionManager) DestroySession(writer http.ResponseWriter, request *http.Request) error {
cookie, err := request.Cookie(SessionCookieName)
clearSessionCookie(writer)
if err != nil || cookie.Value == "" {
return nil
}
manager.mu.Lock()
defer manager.mu.Unlock()
store, err := settings.LoadSessionsOrEmpty(manager.configDir, manager.key)
if err != nil {
return err
}
filtered := make([]settings.SessionRecord, 0, len(store.Sessions))
for _, session := range store.Sessions {
if session.ID == cookie.Value {
continue
}
filtered = append(filtered, session)
}
store.Sessions = filtered
return settings.SaveSessions(manager.configDir, store, manager.key)
}
func newSessionID() (string, error) {
buffer := make([]byte, sessionIDBytes)
if _, err := rand.Read(buffer); err != nil {
return "", fmt.Errorf("session id: %w", err)
}
return base64.RawURLEncoding.EncodeToString(buffer), nil
}
func setSessionCookie(writer http.ResponseWriter, sessionID string, maxAgeSeconds int) {
if maxAgeSeconds < 0 {
maxAgeSeconds = 0
}
http.SetCookie(writer, &http.Cookie{
Name: SessionCookieName,
Value: sessionID,
Path: "/",
MaxAge: maxAgeSeconds,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
}
func clearSessionCookie(writer http.ResponseWriter) {
http.SetCookie(writer, &http.Cookie{
Name: SessionCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
}
// ClientIP extracts the remote IP, preferring X-Forwarded-For when present.
func ClientIP(request *http.Request) string {
if forwarded := strings.TrimSpace(request.Header.Get("X-Forwarded-For")); forwarded != "" {
parts := strings.Split(forwarded, ",")
return strings.TrimSpace(parts[0])
}
host, _, err := net.SplitHostPort(request.RemoteAddr)
if err != nil {
return request.RemoteAddr
}
return host
}
+174
View File
@@ -0,0 +1,174 @@
package auth
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
func TestLookupValidSessionReturnsIdle(t *testing.T) {
configDir := t.TempDir()
key := testSessionKey()
manager := NewSessionManager(configDir, key)
security := settings.DefaultSecuritySettings()
security.IdleTimeoutMinutes = 5
createRec := httptest.NewRecorder()
record, err := manager.CreateSession(createRec, "user-1", security)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
store, err := settings.LoadSessions(configDir, key)
if err != nil {
t.Fatalf("LoadSessions: %v", err)
}
if len(store.Sessions) != 1 {
t.Fatalf("expected 1 session, got %d", len(store.Sessions))
}
store.Sessions[0].LastSeenAt = time.Now().UTC().Add(-6 * time.Minute)
if err := settings.SaveSessions(configDir, store, key); err != nil {
t.Fatalf("SaveSessions: %v", err)
}
request := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
request.AddCookie(&http.Cookie{Name: SessionCookieName, Value: record.ID})
lookupRec := httptest.NewRecorder()
_, err = manager.LookupValidSession(lookupRec, request, security, true)
if !errors.Is(err, ErrSessionIdle) {
t.Fatalf("expected ErrSessionIdle, got %v", err)
}
store, err = settings.LoadSessionsOrEmpty(configDir, key)
if err != nil {
t.Fatalf("LoadSessionsOrEmpty: %v", err)
}
if len(store.Sessions) != 0 {
t.Fatalf("expected idle session removed, got %#v", store.Sessions)
}
}
func TestLookupValidSessionMissingCookie(t *testing.T) {
configDir := t.TempDir()
manager := NewSessionManager(configDir, testSessionKey())
security := settings.DefaultSecuritySettings()
request := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
lookupRec := httptest.NewRecorder()
_, err := manager.LookupValidSession(lookupRec, request, security, true)
if !errors.Is(err, ErrSessionNotFound) {
t.Fatalf("expected ErrSessionNotFound, got %v", err)
}
}
func TestLookupValidSessionUnknownCookie(t *testing.T) {
configDir := t.TempDir()
manager := NewSessionManager(configDir, testSessionKey())
security := settings.DefaultSecuritySettings()
request := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
request.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "unknown-session"})
lookupRec := httptest.NewRecorder()
_, err := manager.LookupValidSession(lookupRec, request, security, true)
if !errors.Is(err, ErrSessionNotFound) {
t.Fatalf("expected ErrSessionNotFound, got %v", err)
}
}
func TestLookupValidSessionWithoutTouchLeavesLastSeenAt(t *testing.T) {
configDir := t.TempDir()
key := testSessionKey()
manager := NewSessionManager(configDir, key)
security := settings.DefaultSecuritySettings()
security.IdleTimeoutMinutes = 30
createRec := httptest.NewRecorder()
record, err := manager.CreateSession(createRec, "user-1", security)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
store, err := settings.LoadSessions(configDir, key)
if err != nil {
t.Fatalf("LoadSessions: %v", err)
}
originalLastSeen := time.Now().UTC().Add(-10 * time.Minute).Truncate(time.Second)
store.Sessions[0].LastSeenAt = originalLastSeen
if err := settings.SaveSessions(configDir, store, key); err != nil {
t.Fatalf("SaveSessions: %v", err)
}
request := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
request.AddCookie(&http.Cookie{Name: SessionCookieName, Value: record.ID})
lookupRec := httptest.NewRecorder()
got, err := manager.LookupValidSession(lookupRec, request, security, false)
if err != nil {
t.Fatalf("LookupValidSession: %v", err)
}
if got.ID != record.ID {
t.Fatalf("expected session id %q, got %q", record.ID, got.ID)
}
store, err = settings.LoadSessions(configDir, key)
if err != nil {
t.Fatalf("LoadSessions after lookup: %v", err)
}
if len(store.Sessions) != 1 {
t.Fatalf("expected 1 session, got %d", len(store.Sessions))
}
if !store.Sessions[0].LastSeenAt.Equal(originalLastSeen) {
t.Fatalf(
"expected LastSeenAt %v unchanged, got %v",
originalLastSeen,
store.Sessions[0].LastSeenAt,
)
}
}
func TestLookupValidSessionIdleWithoutTouchStillIdle(t *testing.T) {
configDir := t.TempDir()
key := testSessionKey()
manager := NewSessionManager(configDir, key)
security := settings.DefaultSecuritySettings()
security.IdleTimeoutMinutes = 5
createRec := httptest.NewRecorder()
record, err := manager.CreateSession(createRec, "user-1", security)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
store, err := settings.LoadSessions(configDir, key)
if err != nil {
t.Fatalf("LoadSessions: %v", err)
}
store.Sessions[0].LastSeenAt = time.Now().UTC().Add(-6 * time.Minute)
if err := settings.SaveSessions(configDir, store, key); err != nil {
t.Fatalf("SaveSessions: %v", err)
}
request := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
request.AddCookie(&http.Cookie{Name: SessionCookieName, Value: record.ID})
lookupRec := httptest.NewRecorder()
_, err = manager.LookupValidSession(lookupRec, request, security, false)
if !errors.Is(err, ErrSessionIdle) {
t.Fatalf("expected ErrSessionIdle, got %v", err)
}
}
func testSessionKey() []byte {
key := make([]byte, 32)
for index := range key {
key[index] = byte(index + 3)
}
return key
}
+73
View File
@@ -0,0 +1,73 @@
package auth
import (
"fmt"
"net"
"strings"
"time"
"golang.org/x/crypto/ssh"
)
const defaultSSHPort = "22"
const sshDialTimeout = 10 * time.Second
// TestSSHConnection dials host over SSH using the given private key and username.
// Host key verification is intentionally skipped for this connectivity check;
// trust-on-first-use / known_hosts can be added later.
func TestSSHConnection(hostIP string, username string, privateKeyPEM string, passphrase string) error {
hostIP = strings.TrimSpace(hostIP)
username = strings.TrimSpace(username)
if hostIP == "" {
return fmt.Errorf("host IP is required")
}
if username == "" {
return fmt.Errorf("username is required")
}
signer, err := parseSigner(privateKeyPEM, passphrase)
if err != nil {
return 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 fmt.Errorf("ssh dial %s: %w", address, err)
}
defer client.Close()
return nil
}
func parseSigner(privateKeyPEM string, passphrase string) (ssh.Signer, error) {
trimmed := strings.TrimSpace(privateKeyPEM)
if trimmed == "" {
return nil, fmt.Errorf("private key is required")
}
var (
signer ssh.Signer
err error
)
if strings.TrimSpace(passphrase) == "" {
signer, err = ssh.ParsePrivateKey([]byte(trimmed))
} else {
signer, err = ssh.ParsePrivateKeyWithPassphrase(
[]byte(trimmed),
[]byte(passphrase),
)
}
if err != nil {
return nil, fmt.Errorf("parse private key: %w", err)
}
return signer, nil
}
+19
View File
@@ -0,0 +1,19 @@
package auth
import (
"testing"
)
func TestTestSSHConnectionRejectsEmptyHost(t *testing.T) {
err := TestSSHConnection("", "clustercanvas", "not-a-key", "")
if err == nil {
t.Fatal("expected error")
}
}
func TestTestSSHConnectionRejectsInvalidKey(t *testing.T) {
err := TestSSHConnection("127.0.0.1", "clustercanvas", "not-a-key", "")
if err == nil {
t.Fatal("expected error")
}
}
+235
View File
@@ -0,0 +1,235 @@
package auth
import (
"bytes"
"fmt"
"net"
"strings"
"time"
"golang.org/x/crypto/ssh"
)
const defaultSSHRunTimeout = 5 * time.Minute
// SSHRunResult captures stdout, stderr, and exit status from a remote command.
type SSHRunResult struct {
Stdout string
Stderr string
ExitCode int
}
// RunSSHShell runs a single shell command on the remote host over SSH.
func RunSSHShell(
hostIP string,
username string,
privateKeyPEM string,
passphrase string,
command string,
env map[string]string,
timeout time.Duration,
) (SSHRunResult, error) {
if strings.TrimSpace(command) == "" {
return SSHRunResult{}, fmt.Errorf("command is required")
}
fullCommand := PrependEnvExports(env, command)
return runSSHSession(hostIP, username, privateKeyPEM, passphrase, fullCommand, timeout)
}
// RunSSHScript uploads and runs a multi-line script via bash -s on the remote host.
// When requiresSudo is true, the remote command is "sudo -n bash -s".
func RunSSHScript(
hostIP string,
username string,
privateKeyPEM string,
passphrase string,
scriptBody string,
env map[string]string,
timeout time.Duration,
requiresSudo bool,
) (SSHRunResult, error) {
if strings.TrimSpace(scriptBody) == "" {
return SSHRunResult{}, fmt.Errorf("script body is required")
}
hostIP = strings.TrimSpace(hostIP)
username = strings.TrimSpace(username)
if hostIP == "" {
return SSHRunResult{}, fmt.Errorf("host IP is required")
}
if username == "" {
return SSHRunResult{}, fmt.Errorf("username is required")
}
if timeout <= 0 {
timeout = defaultSSHRunTimeout
}
signer, err := parseSigner(privateKeyPEM, passphrase)
if err != nil {
return SSHRunResult{}, err
}
config := &ssh.ClientConfig{
User: username,
Auth: []ssh.AuthMethod{
ssh.PublicKeys(signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: sshDialTimeout,
}
address := net.JoinHostPort(hostIP, defaultSSHPort)
client, err := ssh.Dial("tcp", address, config)
if err != nil {
return SSHRunResult{}, fmt.Errorf("ssh dial %s: %w", address, err)
}
defer client.Close()
session, err := client.NewSession()
if err != nil {
return SSHRunResult{}, fmt.Errorf("ssh session: %w", err)
}
defer session.Close()
var stdoutBuf bytes.Buffer
var stderrBuf bytes.Buffer
session.Stdout = &stdoutBuf
session.Stderr = &stderrBuf
session.Stdin = strings.NewReader(scriptBody)
bashCommand := "bash -s"
if requiresSudo {
bashCommand = "sudo -n bash -s"
}
remoteCommand := PrependEnvExports(env, bashCommand)
done := make(chan error, 1)
go func() {
done <- session.Run(remoteCommand)
}()
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case err := <-done:
return finishSSHResult(stdoutBuf.String(), stderrBuf.String(), err)
case <-timer.C:
_ = session.Close()
return SSHRunResult{
Stdout: stdoutBuf.String(),
Stderr: stderrBuf.String(),
ExitCode: -1,
}, fmt.Errorf("ssh command timed out after %s", timeout)
}
}
func runSSHSession(
hostIP string,
username string,
privateKeyPEM string,
passphrase string,
command string,
timeout time.Duration,
) (SSHRunResult, error) {
hostIP = strings.TrimSpace(hostIP)
username = strings.TrimSpace(username)
if hostIP == "" {
return SSHRunResult{}, fmt.Errorf("host IP is required")
}
if username == "" {
return SSHRunResult{}, fmt.Errorf("username is required")
}
if timeout <= 0 {
timeout = defaultSSHRunTimeout
}
signer, err := parseSigner(privateKeyPEM, passphrase)
if err != nil {
return SSHRunResult{}, err
}
config := &ssh.ClientConfig{
User: username,
Auth: []ssh.AuthMethod{
ssh.PublicKeys(signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: sshDialTimeout,
}
address := net.JoinHostPort(hostIP, defaultSSHPort)
client, err := ssh.Dial("tcp", address, config)
if err != nil {
return SSHRunResult{}, fmt.Errorf("ssh dial %s: %w", address, err)
}
defer client.Close()
session, err := client.NewSession()
if err != nil {
return SSHRunResult{}, fmt.Errorf("ssh session: %w", err)
}
defer session.Close()
var stdoutBuf bytes.Buffer
var stderrBuf bytes.Buffer
session.Stdout = &stdoutBuf
session.Stderr = &stderrBuf
done := make(chan error, 1)
go func() {
done <- session.Run(command)
}()
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case err := <-done:
return finishSSHResult(stdoutBuf.String(), stderrBuf.String(), err)
case <-timer.C:
_ = session.Close()
return SSHRunResult{
Stdout: stdoutBuf.String(),
Stderr: stderrBuf.String(),
ExitCode: -1,
}, fmt.Errorf("ssh command timed out after %s", timeout)
}
}
func finishSSHResult(stdout string, stderr string, err error) (SSHRunResult, error) {
result := SSHRunResult{
Stdout: stdout,
Stderr: stderr,
ExitCode: 0,
}
if err == nil {
return result, nil
}
if exitErr, ok := err.(*ssh.ExitError); ok {
result.ExitCode = exitErr.ExitStatus()
return result, nil
}
result.ExitCode = -1
return result, err
}
// PrependEnvExports builds the remote command string with optional export PREFIX; command.
func PrependEnvExports(env map[string]string, command string) string {
if len(env) == 0 {
return command
}
var builder strings.Builder
for name, value := range env {
builder.WriteString("export ")
builder.WriteString(shellQuote(name))
builder.WriteString("=")
builder.WriteString(shellQuote(value))
builder.WriteString("; ")
}
builder.WriteString(command)
return builder.String()
}
func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'"
}
+145
View File
@@ -0,0 +1,145 @@
package auth
import (
"crypto"
"crypto/ed25519"
"crypto/rand"
"crypto/rsa"
"encoding/pem"
"errors"
"fmt"
"strings"
"golang.org/x/crypto/ssh"
)
const (
SSHKeyAlgoEd25519 = "ed25519"
SSHKeyAlgoRSA = "rsa"
DefaultSSHUsername = "clustercanvas"
DefaultRSABits = 4096
DefaultEd25519Rounds = 100
)
// GeneratedSSHKey is the result of generating or importing an SSH key pair.
type GeneratedSSHKey struct {
PrivateKeyPEM string
PublicKey string
Algorithm string
RSABits int
KDFRounds int
}
// GenerateSSHKey creates an OpenSSH private key and authorized_keys public line.
//
// Private keys are written without an OpenSSH passphrase. At-rest protection is
// provided by ClusterCanvas AES-GCM (node-keys.enc). KDFRounds is retained as
// generation metadata (ssh-keygen -a) for future passphrase-protected exports;
// golang.org/x/crypto/ssh hardcodes bcrypt rounds when encrypting.
func GenerateSSHKey(algorithm string, rsaBits int, kdfRounds int) (GeneratedSSHKey, error) {
algorithm = strings.ToLower(strings.TrimSpace(algorithm))
switch algorithm {
case SSHKeyAlgoEd25519:
if kdfRounds <= 0 {
kdfRounds = DefaultEd25519Rounds
}
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return GeneratedSSHKey{}, fmt.Errorf("generate ed25519 key: %w", err)
}
return marshalGeneratedKey(privateKey, SSHKeyAlgoEd25519, 0, kdfRounds)
case SSHKeyAlgoRSA:
if rsaBits == 0 {
rsaBits = DefaultRSABits
}
if !ValidRSABits(rsaBits) {
return GeneratedSSHKey{}, fmt.Errorf("rsa bits must be one of 2048, 3072, or 4096")
}
privateKey, err := rsa.GenerateKey(rand.Reader, rsaBits)
if err != nil {
return GeneratedSSHKey{}, fmt.Errorf("generate rsa key: %w", err)
}
return marshalGeneratedKey(privateKey, SSHKeyAlgoRSA, rsaBits, 0)
default:
return GeneratedSSHKey{}, fmt.Errorf("algorithm must be %q or %q", SSHKeyAlgoEd25519, SSHKeyAlgoRSA)
}
}
// ParseSSHPrivateKey accepts an OpenSSH/PEM private key and derives the public key.
func ParseSSHPrivateKey(privateKeyPEM string) (GeneratedSSHKey, error) {
trimmed := strings.TrimSpace(privateKeyPEM)
if trimmed == "" {
return GeneratedSSHKey{}, errors.New("private_key is required")
}
rawKey, err := ssh.ParseRawPrivateKey([]byte(trimmed))
if err != nil {
return GeneratedSSHKey{}, fmt.Errorf("invalid private key: %w", err)
}
signer, ok := rawKey.(crypto.Signer)
if !ok {
return GeneratedSSHKey{}, errors.New("private key does not support signing")
}
sshPublicKey, err := ssh.NewPublicKey(signer.Public())
if err != nil {
return GeneratedSSHKey{}, fmt.Errorf("derive public key: %w", err)
}
algorithm := SSHKeyAlgoEd25519
rsaBits := 0
switch key := rawKey.(type) {
case *ed25519.PrivateKey, ed25519.PrivateKey:
algorithm = SSHKeyAlgoEd25519
case *rsa.PrivateKey:
algorithm = SSHKeyAlgoRSA
rsaBits = key.N.BitLen()
default:
// Keep OpenSSH type string for unusual keys.
algorithm = sshPublicKey.Type()
}
return GeneratedSSHKey{
PrivateKeyPEM: trimmed + "\n",
PublicKey: strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPublicKey))),
Algorithm: algorithm,
RSABits: rsaBits,
}, nil
}
// ValidRSABits reports whether bits is an allowed RSA size.
func ValidRSABits(bits int) bool {
switch bits {
case 2048, 3072, 4096:
return true
default:
return false
}
}
func marshalGeneratedKey(privateKey crypto.PrivateKey, algorithm string, rsaBits int, kdfRounds int) (GeneratedSSHKey, error) {
block, err := ssh.MarshalPrivateKey(privateKey, "")
if err != nil {
return GeneratedSSHKey{}, fmt.Errorf("marshal private key: %w", err)
}
signer, ok := privateKey.(crypto.Signer)
if !ok {
return GeneratedSSHKey{}, errors.New("private key does not support signing")
}
sshPublicKey, err := ssh.NewPublicKey(signer.Public())
if err != nil {
return GeneratedSSHKey{}, fmt.Errorf("derive public key: %w", err)
}
privatePEM := string(pem.EncodeToMemory(block))
return GeneratedSSHKey{
PrivateKeyPEM: privatePEM,
PublicKey: strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPublicKey))),
Algorithm: algorithm,
RSABits: rsaBits,
KDFRounds: kdfRounds,
}, nil
}
+63
View File
@@ -0,0 +1,63 @@
package auth
import (
"strings"
"testing"
)
func TestGenerateSSHKeyEd25519(t *testing.T) {
generated, err := GenerateSSHKey(SSHKeyAlgoEd25519, 0, 100)
if err != nil {
t.Fatalf("GenerateSSHKey: %v", err)
}
if generated.Algorithm != SSHKeyAlgoEd25519 {
t.Fatalf("algorithm = %q", generated.Algorithm)
}
if generated.KDFRounds != 100 {
t.Fatalf("kdf rounds = %d", generated.KDFRounds)
}
if !strings.Contains(generated.PrivateKeyPEM, "BEGIN OPENSSH PRIVATE KEY") {
t.Fatal("expected openssh private key")
}
if !strings.HasPrefix(generated.PublicKey, "ssh-ed25519 ") {
t.Fatalf("public key = %q", generated.PublicKey)
}
parsed, err := ParseSSHPrivateKey(generated.PrivateKeyPEM)
if err != nil {
t.Fatalf("ParseSSHPrivateKey: %v", err)
}
if parsed.PublicKey != generated.PublicKey {
t.Fatalf("parsed public key mismatch")
}
}
func TestGenerateSSHKeyRSABits(t *testing.T) {
generated, err := GenerateSSHKey(SSHKeyAlgoRSA, 2048, 0)
if err != nil {
t.Fatalf("GenerateSSHKey: %v", err)
}
if generated.RSABits != 2048 {
t.Fatalf("rsa bits = %d", generated.RSABits)
}
if !strings.HasPrefix(generated.PublicKey, "ssh-rsa ") {
t.Fatalf("public key = %q", generated.PublicKey)
}
}
func TestGenerateSSHKeyRejectsInvalidRSABits(t *testing.T) {
_, err := GenerateSSHKey(SSHKeyAlgoRSA, 1024, 0)
if err == nil {
t.Fatal("expected error")
}
}
func TestNewUUIDFormat(t *testing.T) {
value, err := NewUUID()
if err != nil {
t.Fatalf("NewUUID: %v", err)
}
if len(value) != 36 {
t.Fatalf("uuid length = %d", len(value))
}
}
+47
View File
@@ -0,0 +1,47 @@
package auth
import (
"fmt"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
)
const totpIssuer = "ClusterCanvas"
// GenerateTOTPSecret creates a new TOTP key for username.
func GenerateTOTPSecret(username string) (*otp.Key, error) {
key, err := totp.Generate(totp.GenerateOpts{
Issuer: totpIssuer,
AccountName: username,
Period: 30,
Digits: otp.DigitsSix,
Algorithm: otp.AlgorithmSHA1,
})
if err != nil {
return nil, fmt.Errorf("generate totp: %w", err)
}
return key, nil
}
// VerifyTOTPCode validates a TOTP code against a base32 secret.
func VerifyTOTPCode(secret string, code string) bool {
return totp.Validate(code, secret)
}
// OTPAuthURL builds an otpauth URL from an existing secret.
func OTPAuthURL(username string, secret string) (string, error) {
key, err := otp.NewKeyFromURL(
fmt.Sprintf(
"otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=SHA1&digits=6&period=30",
totpIssuer,
username,
secret,
totpIssuer,
),
)
if err != nil {
return "", err
}
return key.URL(), nil
}
+24
View File
@@ -0,0 +1,24 @@
package auth
import (
"crypto/rand"
"fmt"
)
// NewUUID returns a random RFC 4122 version-4 UUID string.
func NewUUID() (string, error) {
buffer := make([]byte, 16)
if _, err := rand.Read(buffer); err != nil {
return "", fmt.Errorf("uuid: %w", err)
}
buffer[6] = (buffer[6] & 0x0f) | 0x40
buffer[8] = (buffer[8] & 0x3f) | 0x80
return fmt.Sprintf(
"%x-%x-%x-%x-%x",
buffer[0:4],
buffer[4:6],
buffer[6:8],
buffer[8:10],
buffer[10:16],
), nil
}
+28
View File
@@ -0,0 +1,28 @@
package auth
import "strings"
// Small denylist of common weak passwords (case-insensitive compare).
var commonPasswords = map[string]struct{}{
"password": {},
"password123": {},
"password1234": {},
"12345678901234": {},
"qwertyuiopasdf": {},
"admin": {},
"adminadmin": {},
"letmein": {},
"welcome": {},
"welcome123": {},
"changeme": {},
"changeme123": {},
"cluster canvas": {},
"clustercanvas": {},
"correct horse battery staple": {},
}
func isCommonPassword(password string) bool {
normalized := strings.ToLower(strings.TrimSpace(password))
_, found := commonPasswords[normalized]
return found
}
+286
View File
@@ -0,0 +1,286 @@
package health
import (
"fmt"
"sync"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
const schedulerTickInterval = 15 * time.Second
// Result is the outcome of a node health check.
type Result struct {
PingOK bool
SSHOK bool
OK bool
Message string
CheckedAt time.Time
}
// Checker runs and schedules per-node health checks.
type Checker struct {
ConfigDir string
Key []byte
mu sync.Mutex
running map[string]struct{}
stopCh chan struct{}
stopped chan struct{}
tickerOn bool
// Optional overrides for tests.
PingFn func(hostIP string) auth.PingResult
SSHFn func(hostIP string, username string, privateKeyPEM string, passphrase string) error
NowFn func() time.Time
}
// NewChecker creates a health checker for the given config directory and key.
func NewChecker(configDir string, key []byte) *Checker {
return &Checker{
ConfigDir: configDir,
Key: key,
running: map[string]struct{}{},
stopCh: make(chan struct{}),
stopped: make(chan struct{}),
}
}
// StartScheduler begins the background health-check ticker.
func (checker *Checker) StartScheduler() {
checker.mu.Lock()
if checker.tickerOn {
checker.mu.Unlock()
return
}
checker.tickerOn = true
checker.mu.Unlock()
go func() {
defer close(checker.stopped)
ticker := time.NewTicker(schedulerTickInterval)
defer ticker.Stop()
for {
select {
case <-checker.stopCh:
return
case <-ticker.C:
checker.tickDueChecks()
}
}
}()
}
// StopScheduler stops the background ticker and waits for it to exit.
func (checker *Checker) StopScheduler() {
checker.mu.Lock()
if !checker.tickerOn {
checker.mu.Unlock()
return
}
checker.tickerOn = false
checker.mu.Unlock()
close(checker.stopCh)
<-checker.stopped
}
func (checker *Checker) tryLockNode(nodeID string) bool {
checker.mu.Lock()
defer checker.mu.Unlock()
if _, ok := checker.running[nodeID]; ok {
return false
}
checker.running[nodeID] = struct{}{}
return true
}
func (checker *Checker) unlockNode(nodeID string) {
checker.mu.Lock()
defer checker.mu.Unlock()
delete(checker.running, nodeID)
}
func (checker *Checker) now() time.Time {
if checker.NowFn != nil {
return checker.NowFn()
}
return time.Now().UTC()
}
func (checker *Checker) tickDueChecks() {
if len(checker.Key) == 0 {
return
}
store, err := settings.LoadNodesOrEmpty(checker.ConfigDir)
if err != nil {
return
}
now := checker.now()
for _, node := range store.Nodes {
if !IsHealthCheckDue(node, now) {
continue
}
nodeID := node.ID
go func() {
_, _ = checker.CheckNodeByID(nodeID, "scheduler")
}()
}
}
// IsHealthCheckDue reports whether a node should be probed now.
func IsHealthCheckDue(node settings.Node, now time.Time) bool {
if node.HealthCheckIntervalSeconds <= 0 {
return false
}
if node.HealthLastCheckedAt == nil {
return true
}
elapsed := now.Sub(node.HealthLastCheckedAt.UTC())
return elapsed >= time.Duration(node.HealthCheckIntervalSeconds)*time.Second
}
// CheckNodeByID loads credentials, runs ping + SSH, and persists the result.
func (checker *Checker) CheckNodeByID(nodeID string, actor string) (settings.Node, error) {
if !checker.tryLockNode(nodeID) {
store, err := settings.LoadNodesOrEmpty(checker.ConfigDir)
if err != nil {
return settings.Node{}, err
}
for _, node := range store.Nodes {
if node.ID == nodeID {
return node, fmt.Errorf("health check already running for node")
}
}
return settings.Node{}, fmt.Errorf("node not found")
}
defer checker.unlockNode(nodeID)
if len(checker.Key) == 0 {
return settings.Node{}, fmt.Errorf("%s is not set", settings.ConfigKeyEnvVar)
}
store, err := settings.LoadNodesOrEmpty(checker.ConfigDir)
if err != nil {
return settings.Node{}, err
}
nodeIndex := -1
var node settings.Node
for index, candidate := range store.Nodes {
if candidate.ID != nodeID {
continue
}
node = candidate
nodeIndex = index
break
}
if nodeIndex < 0 {
return settings.Node{}, fmt.Errorf("node not found")
}
keyStore, err := settings.LoadNodeKeysOrEmpty(checker.ConfigDir, checker.Key)
if err != nil {
return settings.Node{}, err
}
var keyEntry settings.NodeKeyEntry
keyFound := false
for _, entry := range keyStore.Keys {
if entry.NodeID == node.ID {
keyEntry = entry
keyFound = true
break
}
}
if !keyFound {
return settings.Node{}, fmt.Errorf("private key not found for node")
}
result := checker.runChecks(node, keyEntry)
updated := applyHealthResult(node, result)
store.Nodes[nodeIndex] = updated
if err := settings.SaveNodes(checker.ConfigDir, store); err != nil {
return settings.Node{}, err
}
_ = appendHealthAudit(checker.ConfigDir, actor, updated, result.Message)
return updated, nil
}
func (checker *Checker) runChecks(node settings.Node, keyEntry settings.NodeKeyEntry) Result {
checkedAt := checker.now()
pingFn := checker.PingFn
if pingFn == nil {
pingFn = auth.PingHost
}
sshFn := checker.SSHFn
if sshFn == nil {
sshFn = auth.TestSSHConnection
}
pingResult := pingFn(node.HostIP)
sshErr := sshFn(node.HostIP, node.Username, keyEntry.PrivateKey, keyEntry.Passphrase)
sshOK := sshErr == nil
message := buildHealthMessage(pingResult, sshOK, sshErr)
return Result{
PingOK: pingResult.OK,
SSHOK: sshOK,
OK: pingResult.OK && sshOK,
Message: message,
CheckedAt: checkedAt,
}
}
func buildHealthMessage(pingResult auth.PingResult, sshOK bool, sshErr error) string {
var parts []string
if pingResult.OK {
parts = append(parts, fmt.Sprintf("ping ok (%s)", pingResult.Method))
} else if pingResult.Error != nil {
parts = append(parts, fmt.Sprintf("ping failed: %v", pingResult.Error))
} else {
parts = append(parts, "ping failed")
}
if sshOK {
parts = append(parts, "ssh ok")
} else if sshErr != nil {
parts = append(parts, fmt.Sprintf("ssh failed: %v", sshErr))
} else {
parts = append(parts, "ssh failed")
}
return fmt.Sprintf("%s; %s", parts[0], parts[1])
}
func applyHealthResult(node settings.Node, result Result) settings.Node {
checkedAt := result.CheckedAt
pingOK := result.PingOK
sshOK := result.SSHOK
healthOK := result.OK
node.HealthLastCheckedAt = &checkedAt
node.HealthPingOK = &pingOK
node.HealthSSHOK = &sshOK
node.HealthOK = &healthOK
node.HealthMessage = result.Message
return node
}
func appendHealthAudit(configDir string, actor string, node settings.Node, detail string) error {
eventID, err := auth.NewUUID()
if err != nil {
eventID = fmt.Sprintf("health-%d", time.Now().UnixNano())
}
return settings.AppendNodeAuditEvent(configDir, settings.NodeAuditEvent{
ID: eventID,
At: time.Now().UTC(),
Action: settings.NodeAuditActionHealthCheck,
Actor: actor,
NodeID: node.ID,
NodeName: node.Name,
NodeKind: node.Kind,
Detail: detail,
})
}
+164
View File
@@ -0,0 +1,164 @@
package health
import (
"errors"
"testing"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
func TestIsHealthCheckDue(t *testing.T) {
now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC)
t.Run("disabled", func(t *testing.T) {
if IsHealthCheckDue(settings.Node{HealthCheckIntervalSeconds: 0}, now) {
t.Fatal("expected not due")
}
})
t.Run("never checked", func(t *testing.T) {
if !IsHealthCheckDue(settings.Node{HealthCheckIntervalSeconds: 60}, now) {
t.Fatal("expected due")
}
})
t.Run("interval elapsed", func(t *testing.T) {
checked := now.Add(-61 * time.Second)
node := settings.Node{
HealthCheckIntervalSeconds: 60,
HealthLastCheckedAt: &checked,
}
if !IsHealthCheckDue(node, now) {
t.Fatal("expected due")
}
})
t.Run("interval not elapsed", func(t *testing.T) {
checked := now.Add(-30 * time.Second)
node := settings.Node{
HealthCheckIntervalSeconds: 60,
HealthLastCheckedAt: &checked,
}
if IsHealthCheckDue(node, now) {
t.Fatal("expected not due")
}
})
}
func TestCheckNodeByIDPersistsResult(t *testing.T) {
dir := t.TempDir()
keyBytes := make([]byte, 32)
for index := range keyBytes {
keyBytes[index] = byte(index + 1)
}
nodeID := "11111111-2222-4333-8444-555555555555"
store := settings.NodeStore{
Nodes: []settings.Node{{
ID: nodeID,
Kind: settings.NodeKindContainer,
Name: "ct-1",
HostIP: "10.9.9.9",
Username: "clustercanvas",
GroupName: "Administrators",
KeyAlgo: "ed25519",
CreatedAt: time.Now().UTC(),
}},
}
if err := settings.SaveNodes(dir, store); err != nil {
t.Fatalf("SaveNodes: %v", err)
}
if err := settings.SaveNodeKeys(dir, settings.NodeKeyStore{
Keys: []settings.NodeKeyEntry{{
NodeID: nodeID,
PrivateKey: "dummy-key",
Algorithm: "ed25519",
}},
}, keyBytes); err != nil {
t.Fatalf("SaveNodeKeys: %v", err)
}
checker := NewChecker(dir, keyBytes)
checker.NowFn = func() time.Time {
return time.Date(2026, 7, 19, 15, 0, 0, 0, time.UTC)
}
checker.PingFn = func(hostIP string) auth.PingResult {
return auth.PingResult{OK: true, Method: auth.PingMethodTCP}
}
checker.SSHFn = func(hostIP string, username string, privateKeyPEM string, passphrase string) error {
return nil
}
updated, err := checker.CheckNodeByID(nodeID, "tester")
if err != nil {
t.Fatalf("CheckNodeByID: %v", err)
}
if updated.HealthOK == nil || !*updated.HealthOK {
t.Fatalf("health_ok = %#v", updated.HealthOK)
}
if updated.HealthPingOK == nil || !*updated.HealthPingOK {
t.Fatal("expected ping ok")
}
if updated.HealthSSHOK == nil || !*updated.HealthSSHOK {
t.Fatal("expected ssh ok")
}
if updated.HealthMessage == "" {
t.Fatal("expected message")
}
loaded, err := settings.LoadNodes(dir)
if err != nil {
t.Fatalf("LoadNodes: %v", err)
}
if loaded.Nodes[0].HealthOK == nil || !*loaded.Nodes[0].HealthOK {
t.Fatal("persisted health_ok missing")
}
}
func TestCheckNodeByIDRecordsFailure(t *testing.T) {
dir := t.TempDir()
keyBytes := make([]byte, 32)
for index := range keyBytes {
keyBytes[index] = byte(index + 3)
}
nodeID := "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
if err := settings.SaveNodes(dir, settings.NodeStore{
Nodes: []settings.Node{{
ID: nodeID,
Kind: settings.NodeKindVM,
Name: "vm-1",
HostIP: "10.8.8.8",
Username: "clustercanvas",
}},
}); err != nil {
t.Fatalf("SaveNodes: %v", err)
}
if err := settings.SaveNodeKeys(dir, settings.NodeKeyStore{
Keys: []settings.NodeKeyEntry{{
NodeID: nodeID,
PrivateKey: "dummy-key",
Algorithm: "ed25519",
}},
}, keyBytes); err != nil {
t.Fatalf("SaveNodeKeys: %v", err)
}
checker := NewChecker(dir, keyBytes)
checker.PingFn = func(hostIP string) auth.PingResult {
return auth.PingResult{OK: false, Method: auth.PingMethodTCP, Error: errors.New("unreachable")}
}
checker.SSHFn = func(hostIP string, username string, privateKeyPEM string, passphrase string) error {
return errors.New("ssh dial failed")
}
updated, err := checker.CheckNodeByID(nodeID, "tester")
if err != nil {
t.Fatalf("CheckNodeByID: %v", err)
}
if updated.HealthOK == nil || *updated.HealthOK {
t.Fatalf("expected health failure, got %#v", updated.HealthOK)
}
}
+330
View File
@@ -0,0 +1,330 @@
package runner
import (
"errors"
"fmt"
"strconv"
"strings"
"unicode"
)
// ValidateCondition checks that expr is empty or a supported comparison.
func ValidateCondition(expr string) error {
trimmed := strings.TrimSpace(expr)
if trimmed == "" {
return nil
}
_, err := parseCondition(trimmed)
return err
}
const skipDetailMaxValueLen = 80
// EvaluateCondition evaluates a run-if expression against the current var map.
// Empty expr is always true. Eval failures return false with an error.
// On success, detail is a compact comparison (e.g. "$UpdateCount=3 >= 1").
// On a false result, detail is a human-readable skip explanation.
func EvaluateCondition(expr string, vars map[string]string) (ok bool, detail string, err error) {
trimmed := strings.TrimSpace(expr)
if trimmed == "" {
return true, "", nil
}
parsed, err := parseCondition(trimmed)
if err != nil {
return false, "", err
}
detail = formatEvaluatedCondition(parsed, vars)
leftValue := resolveOperand(parsed.left, vars)
rightValue := resolveOperand(parsed.right, vars)
ok, err = compareOperands(leftValue, parsed.op, rightValue)
if err != nil {
return false, detail, err
}
if !ok {
detail = formatSkipDetail(parsed, vars)
}
return ok, detail, nil
}
func formatEvaluatedCondition(parsed parsedCondition, vars map[string]string) string {
return fmt.Sprintf("%s %s %s",
formatOperandForDisplay(parsed.left, vars),
parsed.op,
formatOperandForDisplay(parsed.right, vars),
)
}
// formatSkipDetail builds a user-facing explanation when a run-if condition is false.
func formatSkipDetail(parsed parsedCondition, vars map[string]string) string {
rightDisplay := formatOperandValueForSkip(parsed.right, vars)
if parsed.left.kind == operandVar {
value := ""
if vars != nil {
value = vars[parsed.left.varName]
}
if strings.TrimSpace(value) == "" {
return fmt.Sprintf("$%s was unset, which does not meet run condition (%s %s)",
parsed.left.varName, parsed.op, rightDisplay)
}
return fmt.Sprintf("$%s was %s, which does not meet run condition (%s %s)",
parsed.left.varName, truncateForSkipDetail(value), parsed.op, rightDisplay)
}
return fmt.Sprintf("run condition not met (%s %s %s)",
formatOperandValueForSkip(parsed.left, vars),
parsed.op,
rightDisplay,
)
}
func formatOperandForDisplay(operand conditionOperand, vars map[string]string) string {
if operand.kind == operandVar {
value := ""
if vars != nil {
value = vars[operand.varName]
}
return fmt.Sprintf("$%s=%s", operand.varName, value)
}
return operand.literal
}
func formatOperandValueForSkip(operand conditionOperand, vars map[string]string) string {
if operand.kind == operandVar {
value := ""
if vars != nil {
value = vars[operand.varName]
}
if strings.TrimSpace(value) == "" {
return "unset"
}
return truncateForSkipDetail(value)
}
return operand.literal
}
func truncateForSkipDetail(value string) string {
trimmed := strings.TrimSpace(value)
runes := []rune(trimmed)
if len(runes) <= skipDetailMaxValueLen {
return trimmed
}
return string(runes[:skipDetailMaxValueLen]) + "…"
}
type conditionOp string
const (
opEQ conditionOp = "=="
opNE conditionOp = "!="
opGE conditionOp = ">="
opLE conditionOp = "<="
opGT conditionOp = ">"
opLT conditionOp = "<"
)
type conditionOperand struct {
kind operandKind
varName string
literal string
isNumeric bool
number float64
}
type operandKind int
const (
operandVar operandKind = iota
operandLiteral
)
type parsedCondition struct {
left conditionOperand
op conditionOp
right conditionOperand
}
func parseCondition(expr string) (parsedCondition, error) {
remaining := strings.TrimSpace(expr)
if strings.HasPrefix(strings.ToLower(remaining), "if ") || strings.EqualFold(remaining, "if") {
if len(remaining) < 3 {
return parsedCondition{}, errors.New("condition is empty after if")
}
remaining = strings.TrimSpace(remaining[2:])
}
if remaining == "" {
return parsedCondition{}, errors.New("condition is empty")
}
left, rest, err := parseOperand(remaining)
if err != nil {
return parsedCondition{}, err
}
rest = strings.TrimSpace(rest)
op, afterOp, err := parseOperator(rest)
if err != nil {
return parsedCondition{}, err
}
right, trailing, err := parseOperand(strings.TrimSpace(afterOp))
if err != nil {
return parsedCondition{}, err
}
if strings.TrimSpace(trailing) != "" {
return parsedCondition{}, fmt.Errorf("unexpected trailing input %q", strings.TrimSpace(trailing))
}
return parsedCondition{left: left, op: op, right: right}, nil
}
func parseOperator(input string) (conditionOp, string, error) {
operators := []conditionOp{opEQ, opNE, opGE, opLE, opGT, opLT}
for _, op := range operators {
prefix := string(op)
if strings.HasPrefix(input, prefix) {
return op, input[len(prefix):], nil
}
}
return "", "", errors.New("expected comparison operator (==, !=, >=, <=, >, <)")
}
func parseOperand(input string) (conditionOperand, string, error) {
input = strings.TrimSpace(input)
if input == "" {
return conditionOperand{}, "", errors.New("expected operand")
}
if input[0] == '$' {
name, rest, err := parseDollarName(input)
if err != nil {
return conditionOperand{}, "", err
}
if err := ValidateVariableName(name); err != nil {
return conditionOperand{}, "", err
}
return conditionOperand{kind: operandVar, varName: name}, rest, nil
}
if input[0] == '"' || input[0] == '\'' {
quote := input[0]
var builder strings.Builder
i := 1
for i < len(input) {
ch := input[i]
if ch == quote {
return conditionOperand{
kind: operandLiteral,
literal: builder.String(),
}, input[i+1:], nil
}
builder.WriteByte(ch)
i++
}
return conditionOperand{}, "", errors.New("unclosed string literal")
}
end := 0
for end < len(input) {
ch := rune(input[end])
if unicode.IsSpace(ch) {
break
}
// Stop before a comparison operator that is not part of a number sign.
if end > 0 && (ch == '=' || ch == '!' || ch == '>' || ch == '<') {
break
}
if end == 0 && (ch == '=' || ch == '!' || ch == '>' || ch == '<') {
break
}
end++
}
if end == 0 {
return conditionOperand{}, "", errors.New("expected operand")
}
token := input[:end]
if number, err := strconv.ParseFloat(token, 64); err == nil {
return conditionOperand{
kind: operandLiteral,
literal: token,
isNumeric: true,
number: number,
}, input[end:], nil
}
return conditionOperand{kind: operandLiteral, literal: token}, input[end:], nil
}
func parseDollarName(input string) (string, string, error) {
if !strings.HasPrefix(input, "$") {
return "", "", errors.New("expected $variable")
}
rest := input[1:]
if rest == "" {
return "", "", errors.New("expected variable name after $")
}
if rest[0] == '{' {
closeIdx := strings.IndexByte(rest, '}')
if closeIdx < 0 {
return "", "", errors.New("unclosed ${variable}")
}
name := rest[1:closeIdx]
return name, rest[closeIdx+1:], nil
}
end := 0
for end < len(rest) {
ch := rest[end]
if (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '_' {
end++
continue
}
break
}
if end == 0 {
return "", "", errors.New("expected variable name after $")
}
return rest[:end], rest[end:], nil
}
func resolveOperand(operand conditionOperand, vars map[string]string) conditionOperand {
if operand.kind != operandVar {
return operand
}
value := ""
if vars != nil {
value = vars[operand.varName]
}
resolved := conditionOperand{kind: operandLiteral, literal: value}
if number, err := strconv.ParseFloat(strings.TrimSpace(value), 64); err == nil && strings.TrimSpace(value) != "" {
resolved.isNumeric = true
resolved.number = number
}
return resolved
}
func compareOperands(left conditionOperand, op conditionOp, right conditionOperand) (bool, error) {
if left.isNumeric && right.isNumeric {
switch op {
case opEQ:
return left.number == right.number, nil
case opNE:
return left.number != right.number, nil
case opGE:
return left.number >= right.number, nil
case opLE:
return left.number <= right.number, nil
case opGT:
return left.number > right.number, nil
case opLT:
return left.number < right.number, nil
}
}
// Numeric ops with a non-numeric side evaluate to false (e.g. unset >= 1).
if op == opGE || op == opLE || op == opGT || op == opLT {
return false, nil
}
switch op {
case opEQ:
return left.literal == right.literal, nil
case opNE:
return left.literal != right.literal, nil
default:
return false, fmt.Errorf("unsupported operator %q", op)
}
}
+116
View File
@@ -0,0 +1,116 @@
package runner
import (
"strings"
"testing"
)
func TestValidateCondition(t *testing.T) {
if err := ValidateCondition(""); err != nil {
t.Fatalf("empty: %v", err)
}
if err := ValidateCondition("if $UpdateCount >= 1"); err != nil {
t.Fatalf("valid: %v", err)
}
if err := ValidateCondition("$X == \"ok\""); err != nil {
t.Fatalf("string: %v", err)
}
if err := ValidateCondition("not a condition"); err == nil {
t.Fatal("expected invalid condition error")
}
}
func TestEvaluateCondition(t *testing.T) {
vars := map[string]string{"UpdateCount": "3", "Status": "ready"}
ok, detail, err := EvaluateCondition("if $UpdateCount >= 1", vars)
if err != nil || !ok {
t.Fatalf("expected true, got %v %v", ok, err)
}
if detail != "$UpdateCount=3 >= 1" {
t.Fatalf("detail = %q", detail)
}
ok, detail, err = EvaluateCondition("$UpdateCount >= 10", vars)
if err != nil || ok {
t.Fatalf("expected false, got %v %v", ok, err)
}
wantFail := "$UpdateCount was 3, which does not meet run condition (>= 10)"
if detail != wantFail {
t.Fatalf("detail = %q, want %q", detail, wantFail)
}
ok, detail, err = EvaluateCondition("$Missing >= 1", vars)
if err != nil || ok {
t.Fatalf("unset numeric compare should be false, got %v %v", ok, err)
}
wantUnset := "$Missing was unset, which does not meet run condition (>= 1)"
if detail != wantUnset {
t.Fatalf("detail = %q, want %q", detail, wantUnset)
}
ok, detail, err = EvaluateCondition("$Status == ready", vars)
if err != nil || !ok {
t.Fatalf("string eq expected true, got %v %v", ok, err)
}
if detail != "$Status=ready == ready" {
t.Fatalf("detail = %q", detail)
}
ok, detail, err = EvaluateCondition("", vars)
if err != nil || !ok {
t.Fatalf("empty should be true, got %v %v", ok, err)
}
if detail != "" {
t.Fatalf("empty detail = %q", detail)
}
ok, detail, err = EvaluateCondition("$Status != 'busy'", vars)
if err != nil || !ok {
t.Fatalf("quoted ne expected true, got %v %v", ok, err)
}
if detail != "$Status=ready != busy" {
t.Fatalf("detail = %q", detail)
}
}
func TestEvaluateConditionDetailUnset(t *testing.T) {
ok, detail, err := EvaluateCondition("if $UpdateCount >= 10", map[string]string{})
if err != nil || ok {
t.Fatalf("expected false, got %v %v", ok, err)
}
want := "$UpdateCount was unset, which does not meet run condition (>= 10)"
if detail != want {
t.Fatalf("detail = %q, want %q", detail, want)
}
}
func TestEvaluateConditionSkipDetailZero(t *testing.T) {
ok, detail, err := EvaluateCondition("if $UpdateCount >= 1", map[string]string{"UpdateCount": "0"})
if err != nil || ok {
t.Fatalf("expected false, got %v %v", ok, err)
}
want := "$UpdateCount was 0, which does not meet run condition (>= 1)"
if detail != want {
t.Fatalf("detail = %q, want %q", detail, want)
}
}
func TestEvaluateConditionSkipDetailTruncatesLongValue(t *testing.T) {
longValue := strings.Repeat("x", skipDetailMaxValueLen+20)
ok, detail, err := EvaluateCondition("if $UpdateCount >= 1", map[string]string{"UpdateCount": longValue})
if err != nil || ok {
t.Fatalf("expected false, got %v %v", ok, err)
}
truncated := truncateForSkipDetail(longValue)
want := "$UpdateCount was " + truncated + ", which does not meet run condition (>= 1)"
if detail != want {
t.Fatalf("detail = %q, want %q", detail, want)
}
if !strings.HasSuffix(truncated, "…") {
t.Fatalf("expected truncation ellipsis in %q", truncated)
}
if len([]rune(truncated)) != skipDetailMaxValueLen+1 {
t.Fatalf("truncated length = %d", len([]rune(truncated)))
}
}
+449
View File
@@ -0,0 +1,449 @@
package runner
import (
"fmt"
"strings"
"sync"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
const defaultRunTimeout = 5 * time.Minute
// Executor runs action groups and items against nodes over SSH.
type Executor struct {
ConfigDir string
Key []byte
mu sync.Mutex
running map[string]struct{} // groupID -> running
stopCh chan struct{}
stopped chan struct{}
tickerOn bool
}
// NewExecutor creates an Executor for the given config directory and encryption key.
func NewExecutor(configDir string, key []byte) *Executor {
return &Executor{
ConfigDir: configDir,
Key: key,
running: map[string]struct{}{},
stopCh: make(chan struct{}),
stopped: make(chan struct{}),
}
}
// StartScheduler begins the background schedule ticker (30s).
func (executor *Executor) StartScheduler() {
executor.mu.Lock()
if executor.tickerOn {
executor.mu.Unlock()
return
}
executor.tickerOn = true
executor.mu.Unlock()
go func() {
defer close(executor.stopped)
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-executor.stopCh:
return
case <-ticker.C:
executor.tickSchedules()
}
}
}()
}
// StopScheduler stops the background ticker and waits for it to exit.
func (executor *Executor) StopScheduler() {
executor.mu.Lock()
if !executor.tickerOn {
executor.mu.Unlock()
return
}
executor.tickerOn = false
executor.mu.Unlock()
close(executor.stopCh)
<-executor.stopped
}
func (executor *Executor) tryLockGroup(groupID string) bool {
executor.mu.Lock()
defer executor.mu.Unlock()
if _, ok := executor.running[groupID]; ok {
return false
}
executor.running[groupID] = struct{}{}
return true
}
func (executor *Executor) unlockGroup(groupID string) {
executor.mu.Lock()
defer executor.mu.Unlock()
delete(executor.running, groupID)
}
func (executor *Executor) tickSchedules() {
store, err := settings.LoadNodeActionGroupsOrEmpty(executor.ConfigDir)
if err != nil {
return
}
now := time.Now()
for _, group := range store.Groups {
if !settings.IsScheduleDue(group, now) {
continue
}
groupCopy := group
go func() {
_, _ = executor.RunGroup(groupCopy, settings.ActionRunTriggerSchedule, "scheduler")
}()
}
}
// ResolvedAction is the effective action definition after library resolution.
type ResolvedAction struct {
Name string
Description string
Kind settings.ActionKind
Body string
Env []settings.ActionEnvVar
RequiresSudo bool
Source settings.ActionItemSource
}
// ResolveItem resolves a library or local item to an executable action.
func ResolveItem(item settings.NodeActionItem, library []settings.Action) (ResolvedAction, error) {
switch item.Source {
case settings.ActionItemSourceLibrary:
if strings.TrimSpace(item.LibraryActionID) == "" {
return ResolvedAction{}, fmt.Errorf("library action id is required")
}
for _, action := range library {
if action.ID == item.LibraryActionID {
env := action.Env
if env == nil {
env = []settings.ActionEnvVar{}
}
return ResolvedAction{
Name: action.Name,
Description: action.Description,
Kind: action.Kind,
Body: action.Body,
Env: env,
RequiresSudo: action.RequiresSudo,
Source: settings.ActionItemSourceLibrary,
}, nil
}
}
return ResolvedAction{}, fmt.Errorf("library action %q not found", item.LibraryActionID)
case settings.ActionItemSourceLocal:
if strings.TrimSpace(item.Name) == "" {
return ResolvedAction{}, fmt.Errorf("local action name is required")
}
if strings.TrimSpace(item.Body) == "" {
return ResolvedAction{}, fmt.Errorf("local action body is required")
}
if item.Kind != settings.ActionKindShell && item.Kind != settings.ActionKindScript {
return ResolvedAction{}, fmt.Errorf("local action kind must be shell or script")
}
env := item.Env
if env == nil {
env = []settings.ActionEnvVar{}
}
return ResolvedAction{
Name: item.Name,
Description: item.Description,
Kind: item.Kind,
Body: item.Body,
Env: env,
RequiresSudo: item.RequiresSudo,
Source: settings.ActionItemSourceLocal,
}, nil
default:
return ResolvedAction{}, fmt.Errorf("unknown action item source %q", item.Source)
}
}
// WrapShellWithSudo prepends "sudo -n " when requiresSudo is true.
func WrapShellWithSudo(command string, requiresSudo bool) string {
if !requiresSudo {
return command
}
return "sudo -n " + command
}
// ScriptRemoteCommand is the remote argv used for script actions (body on stdin).
func ScriptRemoteCommand(requiresSudo bool) string {
if requiresSudo {
return "sudo -n bash -s"
}
return "bash -s"
}
// BuildRemoteCommand returns the final SSH exec string for an action (including env exports).
func BuildRemoteCommand(
kind settings.ActionKind,
body string,
requiresSudo bool,
env map[string]string,
) string {
var command string
switch kind {
case settings.ActionKindShell:
command = WrapShellWithSudo(body, requiresSudo)
case settings.ActionKindScript:
command = ScriptRemoteCommand(requiresSudo)
default:
command = body
}
return auth.PrependEnvExports(env, command)
}
// RunGroup executes all items in order, appends a log file entry, and updates LastRunAt.
func (executor *Executor) RunGroup(
group settings.NodeActionGroup,
trigger settings.ActionRunTrigger,
actor string,
) (settings.ActionGroupRunRecord, error) {
if !executor.tryLockGroup(group.ID) {
return settings.ActionGroupRunRecord{}, fmt.Errorf("action group is already running")
}
defer executor.unlockGroup(group.ID)
return executor.runItems(group, group.Items, trigger, actor)
}
// RunItem executes a single item (still logged under the parent group file).
func (executor *Executor) RunItem(
group settings.NodeActionGroup,
item settings.NodeActionItem,
trigger settings.ActionRunTrigger,
actor string,
) (settings.ActionGroupRunRecord, error) {
lockKey := group.ID + ":" + item.ID
if !executor.tryLockGroup(lockKey) {
return settings.ActionGroupRunRecord{}, fmt.Errorf("action is already running")
}
defer executor.unlockGroup(lockKey)
return executor.runItems(group, []settings.NodeActionItem{item}, trigger, actor)
}
func (executor *Executor) runItems(
group settings.NodeActionGroup,
items []settings.NodeActionItem,
trigger settings.ActionRunTrigger,
actor string,
) (settings.ActionGroupRunRecord, error) {
runID, err := auth.NewUUID()
if err != nil {
return settings.ActionGroupRunRecord{}, err
}
node, privateKey, passphrase, err := executor.loadNodeCredentials(group.NodeID)
if err != nil {
return settings.ActionGroupRunRecord{}, err
}
libraryStore, err := settings.LoadActionsOrSeed(executor.ConfigDir)
if err != nil {
return settings.ActionGroupRunRecord{}, err
}
startedAt := time.Now().UTC()
results := make([]settings.ActionItemRunResult, 0, len(items))
runVars := map[string]string{}
for _, item := range items {
itemStarted := time.Now().UTC()
resolved, resolveErr := ResolveItem(item, libraryStore.Actions)
if resolveErr != nil {
results = append(results, settings.ActionItemRunResult{
ItemID: item.ID,
ActionName: item.Name,
Source: string(item.Source),
ExitCode: -1,
Stdout: "",
Stderr: "",
Error: resolveErr.Error(),
StartedAt: itemStarted,
FinishedAt: time.Now().UTC(),
})
continue
}
runIf := strings.TrimSpace(item.RunIf)
if runIf != "" {
shouldRun, detail, condErr := EvaluateCondition(runIf, runVars)
if condErr != nil || !shouldRun {
skipReason := fmt.Sprintf("%s skipped: run condition was not met", resolved.Name)
if detail != "" {
skipReason = fmt.Sprintf("%s skipped: %s", resolved.Name, detail)
}
if condErr != nil {
skipReason = fmt.Sprintf("%s skipped because condition error: %s", resolved.Name, condErr.Error())
}
results = append(results, settings.ActionItemRunResult{
ItemID: item.ID,
ActionName: resolved.Name,
Source: string(resolved.Source),
ExitCode: 0,
Stdout: "",
Stderr: "",
Skipped: true,
SkipReason: skipReason,
StartedAt: itemStarted,
FinishedAt: time.Now().UTC(),
})
continue
}
}
rawEnv := make(map[string]string, len(resolved.Env))
for _, envVar := range resolved.Env {
rawEnv[envVar.Name] = envVar.Value
}
envMap := make(map[string]string, len(resolved.Env))
for _, envVar := range resolved.Env {
expandedValue := auth.ExpandPlaceholders(envVar.Value, auth.PlaceholderContext{
Host: node.Name,
IP: node.HostIP,
Username: node.Username,
Env: rawEnv,
Secrets: map[string]string{},
})
envMap[envVar.Name] = ExpandDollarVars(expandedValue, runVars)
}
expandedBody := auth.ExpandPlaceholders(resolved.Body, auth.PlaceholderContext{
Host: node.Name,
IP: node.HostIP,
Username: node.Username,
Env: envMap,
Secrets: map[string]string{},
})
expandedBody = ExpandDollarVars(expandedBody, runVars)
remoteCommand := BuildRemoteCommand(
resolved.Kind,
expandedBody,
resolved.RequiresSudo,
envMap,
)
var sshResult auth.SSHRunResult
var runErr error
switch resolved.Kind {
case settings.ActionKindShell:
sshResult, runErr = auth.RunSSHShell(
node.HostIP,
node.Username,
privateKey,
passphrase,
WrapShellWithSudo(expandedBody, resolved.RequiresSudo),
envMap,
defaultRunTimeout,
)
case settings.ActionKindScript:
sshResult, runErr = auth.RunSSHScript(
node.HostIP,
node.Username,
privateKey,
passphrase,
expandedBody,
envMap,
defaultRunTimeout,
resolved.RequiresSudo,
)
default:
runErr = fmt.Errorf("unsupported action kind %q", resolved.Kind)
}
itemResult := settings.ActionItemRunResult{
ItemID: item.ID,
ActionName: resolved.Name,
Source: string(resolved.Source),
Kind: resolved.Kind,
RemoteCommand: remoteCommand,
SSHUsername: node.Username,
ExitCode: sshResult.ExitCode,
Stdout: sshResult.Stdout,
Stderr: sshResult.Stderr,
StartedAt: itemStarted,
FinishedAt: time.Now().UTC(),
}
if runErr != nil {
itemResult.Error = runErr.Error()
if itemResult.ExitCode == 0 {
itemResult.ExitCode = -1
}
} else if setName := strings.TrimSpace(item.SetVariable); setName != "" {
captured := strings.TrimSpace(sshResult.Stdout)
runVars[setName] = captured
itemResult.SetVariable = setName
itemResult.VariableValue = captured
}
results = append(results, itemResult)
}
finishedAt := time.Now().UTC()
record := settings.ActionGroupRunRecord{
ID: runID,
NodeID: group.NodeID,
GroupID: group.ID,
GroupName: group.Name,
Trigger: trigger,
Actor: actor,
StartedAt: startedAt,
FinishedAt: finishedAt,
Actions: results,
}
if err := settings.AppendActionLogRun(executor.ConfigDir, record); err != nil {
return record, fmt.Errorf("append action log: %w", err)
}
if err := settings.UpdateGroupLastRunAt(executor.ConfigDir, group.ID, finishedAt); err != nil {
return record, fmt.Errorf("update last run: %w", err)
}
return record, nil
}
func (executor *Executor) loadNodeCredentials(nodeID string) (settings.Node, string, string, error) {
if len(executor.Key) == 0 {
return settings.Node{}, "", "", fmt.Errorf("%s is not set", settings.ConfigKeyEnvVar)
}
nodeStore, err := settings.LoadNodesOrEmpty(executor.ConfigDir)
if err != nil {
return settings.Node{}, "", "", err
}
var node settings.Node
found := false
for _, candidate := range nodeStore.Nodes {
if candidate.ID == nodeID {
node = candidate
found = true
break
}
}
if !found {
return settings.Node{}, "", "", fmt.Errorf("node not found")
}
keyStore, err := settings.LoadNodeKeysOrEmpty(executor.ConfigDir, executor.Key)
if err != nil {
return settings.Node{}, "", "", err
}
for _, entry := range keyStore.Keys {
if entry.NodeID == nodeID {
return node, entry.PrivateKey, entry.Passphrase, nil
}
}
return settings.Node{}, "", "", fmt.Errorf("node private key not found")
}
@@ -0,0 +1,99 @@
package runner
import (
"testing"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
func TestWrapShellWithSudo(t *testing.T) {
if got := WrapShellWithSudo("/usr/bin/systemctl restart myservice", false); got != "/usr/bin/systemctl restart myservice" {
t.Fatalf("without sudo = %q", got)
}
if got := WrapShellWithSudo("/usr/bin/systemctl restart myservice", true); got != "sudo -n /usr/bin/systemctl restart myservice" {
t.Fatalf("with sudo = %q", got)
}
}
func TestScriptRemoteCommand(t *testing.T) {
if got := ScriptRemoteCommand(false); got != "bash -s" {
t.Fatalf("without sudo = %q", got)
}
if got := ScriptRemoteCommand(true); got != "sudo -n bash -s" {
t.Fatalf("with sudo = %q", got)
}
}
func TestBuildRemoteCommand(t *testing.T) {
shellCmd := BuildRemoteCommand(settings.ActionKindShell, "apt update", true, nil)
if shellCmd != "sudo -n apt update" {
t.Fatalf("shell = %q", shellCmd)
}
scriptCmd := BuildRemoteCommand(settings.ActionKindScript, "apt update", true, nil)
if scriptCmd != "sudo -n bash -s" {
t.Fatalf("script = %q", scriptCmd)
}
withEnv := BuildRemoteCommand(
settings.ActionKindShell,
"apt update",
true,
map[string]string{"FOO": "bar"},
)
if withEnv != "export 'FOO'='bar'; sudo -n apt update" {
t.Fatalf("with env = %q", withEnv)
}
}
func TestResolveItemRequiresSudo(t *testing.T) {
library := []settings.Action{
{
ID: "lib-1",
Name: "Restart",
Kind: settings.ActionKindShell,
Body: "/usr/bin/systemctl restart myservice",
RequiresSudo: true,
Env: []settings.ActionEnvVar{},
},
}
resolved, err := ResolveItem(settings.NodeActionItem{
ID: "i1",
Source: settings.ActionItemSourceLibrary,
LibraryActionID: "lib-1",
}, library)
if err != nil {
t.Fatalf("resolve library: %v", err)
}
if !resolved.RequiresSudo || resolved.Body != "/usr/bin/systemctl restart myservice" {
t.Fatalf("library resolved = %#v", resolved)
}
local, err := ResolveItem(settings.NodeActionItem{
ID: "i2",
Source: settings.ActionItemSourceLocal,
Name: "Local restart",
Kind: settings.ActionKindShell,
Body: "/usr/bin/true",
RequiresSudo: true,
}, library)
if err != nil {
t.Fatalf("resolve local: %v", err)
}
if !local.RequiresSudo {
t.Fatalf("local resolved = %#v", local)
}
noSudo, err := ResolveItem(settings.NodeActionItem{
ID: "i3",
Source: settings.ActionItemSourceLocal,
Name: "Uptime",
Kind: settings.ActionKindShell,
Body: "uptime",
}, library)
if err != nil {
t.Fatalf("resolve no-sudo: %v", err)
}
if noSudo.RequiresSudo {
t.Fatalf("expected RequiresSudo false, got %#v", noSudo)
}
}
+53
View File
@@ -0,0 +1,53 @@
package runner
import (
"testing"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
func TestResolveItemLibraryAndLocal(t *testing.T) {
library := []settings.Action{
{
ID: settings.BuiltinActionUptimeID,
Name: "Uptime",
Kind: settings.ActionKindShell,
Body: "uptime",
Env: []settings.ActionEnvVar{},
},
}
resolved, err := ResolveItem(settings.NodeActionItem{
ID: "i1",
Source: settings.ActionItemSourceLibrary,
LibraryActionID: settings.BuiltinActionUptimeID,
}, library)
if err != nil {
t.Fatalf("ResolveItem library: %v", err)
}
if resolved.Name != "Uptime" || resolved.Body != "uptime" {
t.Fatalf("resolved = %#v", resolved)
}
local, err := ResolveItem(settings.NodeActionItem{
ID: "i2",
Source: settings.ActionItemSourceLocal,
Name: "Echo",
Kind: settings.ActionKindShell,
Body: "echo hi",
Env: []settings.ActionEnvVar{},
}, library)
if err != nil {
t.Fatalf("ResolveItem local: %v", err)
}
if local.Name != "Echo" {
t.Fatalf("local = %#v", local)
}
if _, err := ResolveItem(settings.NodeActionItem{
Source: settings.ActionItemSourceLibrary,
LibraryActionID: "missing",
}, library); err == nil {
t.Fatal("expected missing library action error")
}
}
@@ -0,0 +1,84 @@
package runner
import (
"strings"
"testing"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
func TestRunScopedVariableSequence(t *testing.T) {
vars := map[string]string{}
// Action 1 captures trimmed stdout into UpdateCount.
captured := strings.TrimSpace("3\n")
vars["UpdateCount"] = captured
if vars["UpdateCount"] != "3" {
t.Fatalf("captured = %q", vars["UpdateCount"])
}
// Action 2 condition passes and body expands.
ok, detail, err := EvaluateCondition("if $UpdateCount >= 1", vars)
if err != nil || !ok {
t.Fatalf("condition should pass: ok=%v err=%v", ok, err)
}
if detail != "$UpdateCount=3 >= 1" {
t.Fatalf("detail = %q", detail)
}
body := ExpandDollarVars("echo upgrades=$UpdateCount", vars)
if body != "echo upgrades=3" {
t.Fatalf("body = %q", body)
}
// Action 3 condition fails → skipped.
ok, detail, err = EvaluateCondition("if $UpdateCount >= 10", vars)
if err != nil || ok {
t.Fatalf("condition should fail: ok=%v err=%v", ok, err)
}
wantFail := "$UpdateCount was 3, which does not meet run condition (>= 10)"
if detail != wantFail {
t.Fatalf("detail = %q, want %q", detail, wantFail)
}
// A fresh run starts with an empty map (no leak).
nextRun := map[string]string{}
ok, detail, err = EvaluateCondition("if $UpdateCount >= 1", nextRun)
if err != nil || ok {
t.Fatalf("unset in new run should be false: ok=%v err=%v", ok, err)
}
wantUnset := "$UpdateCount was unset, which does not meet run condition (>= 1)"
if detail != wantUnset {
t.Fatalf("detail = %q, want %q", detail, wantUnset)
}
}
func TestSkipResultShape(t *testing.T) {
item := settings.NodeActionItem{
ID: "i1",
Name: "Upgrade",
RunIf: "if $UpdateCount >= 1",
}
vars := map[string]string{"UpdateCount": "0"}
shouldRun, detail, condErr := EvaluateCondition(item.RunIf, vars)
if condErr != nil {
t.Fatalf("eval: %v", condErr)
}
if shouldRun {
t.Fatal("expected skip")
}
skipReason := item.Name + " skipped: run condition was not met"
if detail != "" {
skipReason = item.Name + " skipped: " + detail
}
result := settings.ActionItemRunResult{
ItemID: item.ID,
ActionName: item.Name,
Skipped: true,
SkipReason: skipReason,
ExitCode: 0,
}
want := "Upgrade skipped: $UpdateCount was 0, which does not meet run condition (>= 1)"
if !result.Skipped || result.SkipReason != want {
t.Fatalf("result = %#v, want SkipReason %q", result, want)
}
}
+52
View File
@@ -0,0 +1,52 @@
package runner
import (
"errors"
"fmt"
"regexp"
"strings"
)
var (
variableNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
// Matches $Name or ${Name}; only known names in the vars map are replaced.
dollarVarPattern = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)`)
)
// ValidateVariableName checks that name is a valid run-scoped variable identifier.
func ValidateVariableName(name string) error {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
return errors.New("variable name is required")
}
if trimmed != name {
return errors.New("variable name must not have leading or trailing spaces")
}
if !variableNamePattern.MatchString(trimmed) {
return fmt.Errorf("invalid variable name %q", name)
}
return nil
}
// ExpandDollarVars replaces $Name and ${Name} when Name exists in vars.
// Unknown identifiers are left unchanged for the remote shell.
func ExpandDollarVars(text string, vars map[string]string) string {
if vars == nil || len(vars) == 0 || text == "" {
return text
}
return dollarVarPattern.ReplaceAllStringFunc(text, func(match string) string {
submatches := dollarVarPattern.FindStringSubmatch(match)
if len(submatches) < 3 {
return match
}
name := submatches[1]
if name == "" {
name = submatches[2]
}
value, ok := vars[name]
if !ok {
return match
}
return value
})
}
+33
View File
@@ -0,0 +1,33 @@
package runner
import "testing"
func TestValidateVariableName(t *testing.T) {
if err := ValidateVariableName("UpdateCount"); err != nil {
t.Fatalf("UpdateCount: %v", err)
}
if err := ValidateVariableName("_x"); err != nil {
t.Fatalf("_x: %v", err)
}
if err := ValidateVariableName(""); err == nil {
t.Fatal("expected empty name error")
}
if err := ValidateVariableName("1bad"); err == nil {
t.Fatal("expected invalid name error")
}
if err := ValidateVariableName("bad-name"); err == nil {
t.Fatal("expected invalid name error")
}
}
func TestExpandDollarVars(t *testing.T) {
vars := map[string]string{"UpdateCount": "3", "Name": "web"}
got := ExpandDollarVars("count=$UpdateCount host=${Name}-01 keep=$HOME", vars)
want := "count=3 host=web-01 keep=$HOME"
if got != want {
t.Fatalf("got %q want %q", got, want)
}
if ExpandDollarVars("$Missing", nil) != "$Missing" {
t.Fatal("nil vars should leave tokens unchanged")
}
}
+174
View File
@@ -0,0 +1,174 @@
package settings
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
// MaxActionLogRuns is the retention cap per action-group log file.
const MaxActionLogRuns = 200
// ActionLogDir returns action-logs/<nodeID> under the config dir.
func ActionLogDir(configDir string, nodeID string) string {
return filepath.Join(configDir, ActionLogsDirName, nodeID)
}
// ActionLogFilePath returns the JSON log path for a node + group name.
func ActionLogFilePath(configDir string, nodeID string, groupName string) string {
filename := DiskSafeFilename(groupName) + ".json"
return filepath.Join(ActionLogDir(configDir, nodeID), filename)
}
// AppendActionLogRun appends a run record to the group's log file and trims retention.
func AppendActionLogRun(configDir string, record ActionGroupRunRecord) error {
if strings.TrimSpace(record.NodeID) == "" {
return fmt.Errorf("node id is required")
}
if strings.TrimSpace(record.GroupName) == "" {
return fmt.Errorf("group name is required")
}
dir := ActionLogDir(configDir, record.NodeID)
if err := os.MkdirAll(dir, 0o750); err != nil {
return fmt.Errorf("create action log dir: %w", err)
}
path := ActionLogFilePath(configDir, record.NodeID, record.GroupName)
file, err := LoadActionLogFileOrEmpty(path, record.NodeID, record.GroupID, record.GroupName)
if err != nil {
return err
}
file.NodeID = record.NodeID
file.GroupID = record.GroupID
file.GroupName = record.GroupName
file.Runs = append(file.Runs, record)
if len(file.Runs) > MaxActionLogRuns {
file.Runs = file.Runs[len(file.Runs)-MaxActionLogRuns:]
}
return SaveActionLogFile(path, file)
}
// LoadActionLogFile reads a log JSON file from path.
func LoadActionLogFile(path string) (ActionLogFile, error) {
payload, err := os.ReadFile(path)
if err != nil {
return ActionLogFile{}, err
}
var file ActionLogFile
if err := json.Unmarshal(payload, &file); err != nil {
return ActionLogFile{}, fmt.Errorf("parse action log: %w", err)
}
if file.Runs == nil {
file.Runs = []ActionGroupRunRecord{}
}
return file, nil
}
// LoadActionLogFileOrEmpty returns an empty file when missing.
func LoadActionLogFileOrEmpty(
path string,
nodeID string,
groupID string,
groupName string,
) (ActionLogFile, error) {
file, err := LoadActionLogFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return ActionLogFile{
NodeID: nodeID,
GroupID: groupID,
GroupName: groupName,
Runs: []ActionGroupRunRecord{},
}, nil
}
return ActionLogFile{}, err
}
return file, nil
}
// SaveActionLogFile writes a log JSON file with mode 0640.
func SaveActionLogFile(path string, file ActionLogFile) error {
if file.Runs == nil {
file.Runs = []ActionGroupRunRecord{}
}
payload, err := json.MarshalIndent(file, "", " ")
if err != nil {
return fmt.Errorf("encode action log: %w", err)
}
payload = append(payload, '\n')
if err := os.WriteFile(path, payload, 0o640); err != nil {
return fmt.Errorf("write action log: %w", err)
}
return nil
}
// ListActionLogFiles lists JSON log files for a node.
func ListActionLogFiles(configDir string, nodeID string) ([]ActionLogFileInfo, error) {
dir := ActionLogDir(configDir, nodeID)
entries, err := os.ReadDir(dir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return []ActionLogFileInfo{}, nil
}
return nil, fmt.Errorf("list action logs: %w", err)
}
result := make([]ActionLogFileInfo, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasSuffix(name, ".json") {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
groupName := strings.TrimSuffix(name, ".json")
groupID := ""
file, loadErr := LoadActionLogFile(filepath.Join(dir, name))
if loadErr == nil {
if file.GroupName != "" {
groupName = file.GroupName
}
groupID = file.GroupID
}
result = append(result, ActionLogFileInfo{
Filename: name,
GroupName: groupName,
GroupID: groupID,
ModTime: info.ModTime().UTC(),
SizeBytes: info.Size(),
})
}
return result, nil
}
// ReadActionLogFileByName reads one log file for a node after validating the name.
func ReadActionLogFileByName(configDir string, nodeID string, filename string) (ActionLogFile, error) {
safeName := filepath.Base(strings.TrimSpace(filename))
if safeName == "." || safeName == ".." || safeName == "" {
return ActionLogFile{}, fmt.Errorf("invalid log filename")
}
if !strings.HasSuffix(safeName, ".json") {
return ActionLogFile{}, fmt.Errorf("invalid log filename")
}
if strings.Contains(safeName, "/") || strings.Contains(safeName, `\`) {
return ActionLogFile{}, fmt.Errorf("invalid log filename")
}
path := filepath.Join(ActionLogDir(configDir, nodeID), safeName)
cleaned := filepath.Clean(path)
if !strings.HasPrefix(cleaned, filepath.Clean(ActionLogDir(configDir, nodeID))+string(os.PathSeparator)) {
return ActionLogFile{}, fmt.Errorf("invalid log filename")
}
return LoadActionLogFile(cleaned)
}
+127
View File
@@ -0,0 +1,127 @@
package settings
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
)
// Stable IDs for seeded built-in actions.
const (
BuiltinActionUptimeID = "00000000-0000-4000-8000-000000000001"
BuiltinActionDiskUsageID = "00000000-0000-4000-8000-000000000002"
BuiltinActionSysInfoID = "00000000-0000-4000-8000-000000000003"
)
// DefaultBuiltinActions returns the read-only seed actions.
func DefaultBuiltinActions() []Action {
now := time.Now().UTC()
return []Action{
{
ID: BuiltinActionUptimeID,
Name: "Uptime",
Description: "Show how long the target system has been running.",
Kind: ActionKindShell,
Body: "uptime",
Env: []ActionEnvVar{},
Builtin: true,
CreatedAt: now,
UpdatedAt: now,
},
{
ID: BuiltinActionDiskUsageID,
Name: "Disk usage",
Description: "Show filesystem disk space usage on the target.",
Kind: ActionKindShell,
Body: "df -h",
Env: []ActionEnvVar{},
Builtin: true,
CreatedAt: now,
UpdatedAt: now,
},
{
ID: BuiltinActionSysInfoID,
Name: "System info",
Description: "Print kernel and OS release details from the target.",
Kind: ActionKindScript,
Body: `uname -a
if [ -f /etc/os-release ]; then
. /etc/os-release
echo "OS: ${NAME:-unknown} ${VERSION:-}"
fi`,
Env: []ActionEnvVar{},
Builtin: true,
CreatedAt: now,
UpdatedAt: now,
},
}
}
// LoadActions reads and parses actions.json from dir.
func LoadActions(dir string) (ActionStore, error) {
path := filepath.Join(dir, ActionsFileName)
payload, err := os.ReadFile(path)
if err != nil {
return ActionStore{}, err
}
var store ActionStore
if err := json.Unmarshal(payload, &store); err != nil {
return ActionStore{}, fmt.Errorf("parse actions: %w", err)
}
if store.Actions == nil {
store.Actions = []Action{}
}
for index := range store.Actions {
if store.Actions[index].Env == nil {
store.Actions[index].Env = []ActionEnvVar{}
}
}
return store, nil
}
// LoadActionsOrSeed returns seeded builtins (and persists them) when actions.json is missing.
func LoadActionsOrSeed(dir string) (ActionStore, error) {
store, err := LoadActions(dir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
seeded := ActionStore{Actions: DefaultBuiltinActions()}
if saveErr := SaveActions(dir, seeded); saveErr != nil {
return ActionStore{}, saveErr
}
return seeded, nil
}
return ActionStore{}, err
}
return store, nil
}
// SaveActions writes actions.json to dir with mode 0640.
func SaveActions(dir string, store ActionStore) error {
if err := ensureConfigDir(dir); err != nil {
return err
}
if store.Actions == nil {
store.Actions = []Action{}
}
for index := range store.Actions {
if store.Actions[index].Env == nil {
store.Actions[index].Env = []ActionEnvVar{}
}
}
payload, err := json.MarshalIndent(store, "", " ")
if err != nil {
return fmt.Errorf("encode actions: %w", err)
}
payload = append(payload, '\n')
path := filepath.Join(dir, ActionsFileName)
if err := os.WriteFile(path, payload, 0o640); err != nil {
return fmt.Errorf("write actions: %w", err)
}
return nil
}
+68
View File
@@ -0,0 +1,68 @@
package settings
import (
"os"
"path/filepath"
"testing"
)
func TestLoadActionsOrSeedCreatesBuiltins(t *testing.T) {
dir := t.TempDir()
store, err := LoadActionsOrSeed(dir)
if err != nil {
t.Fatalf("LoadActionsOrSeed: %v", err)
}
if len(store.Actions) != 3 {
t.Fatalf("expected 3 builtins, got %d", len(store.Actions))
}
for _, action := range store.Actions {
if !action.Builtin {
t.Fatalf("expected builtin action, got %#v", action)
}
}
path := filepath.Join(dir, ActionsFileName)
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected actions.json to exist: %v", err)
}
reloaded, err := LoadActions(dir)
if err != nil {
t.Fatalf("LoadActions: %v", err)
}
if len(reloaded.Actions) != 3 {
t.Fatalf("reloaded len = %d", len(reloaded.Actions))
}
}
func TestActionsRoundTrip(t *testing.T) {
dir := t.TempDir()
store := ActionStore{
Actions: append(DefaultBuiltinActions(), Action{
ID: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
Name: "Custom",
Description: "A custom shell action",
Kind: ActionKindShell,
Body: "echo hello",
Env: []ActionEnvVar{
{Name: "APP_HOME", Value: "/opt/app"},
},
Builtin: false,
}),
}
if err := SaveActions(dir, store); err != nil {
t.Fatalf("SaveActions: %v", err)
}
loaded, err := LoadActions(dir)
if err != nil {
t.Fatalf("LoadActions: %v", err)
}
if len(loaded.Actions) != 4 {
t.Fatalf("loaded len = %d", len(loaded.Actions))
}
custom := loaded.Actions[3]
if custom.Name != "Custom" || len(custom.Env) != 1 || custom.Env[0].Name != "APP_HOME" {
t.Fatalf("custom = %#v", custom)
}
}
+77
View File
@@ -0,0 +1,77 @@
package settings
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
)
// MaxAuthAuditEvents is the retention cap for auth-audit.json.
const MaxAuthAuditEvents = 1000
// LoadAuthAudit reads and parses auth-audit.json from dir.
func LoadAuthAudit(dir string) (AuthAuditStore, error) {
path := filepath.Join(dir, AuthAuditFileName)
payload, err := os.ReadFile(path)
if err != nil {
return AuthAuditStore{}, err
}
var store AuthAuditStore
if err := json.Unmarshal(payload, &store); err != nil {
return AuthAuditStore{}, fmt.Errorf("parse auth audit: %w", err)
}
if store.Events == nil {
store.Events = []AuthAuditEvent{}
}
return store, nil
}
// LoadAuthAuditOrEmpty returns an empty store when auth-audit.json is missing.
func LoadAuthAuditOrEmpty(dir string) (AuthAuditStore, error) {
store, err := LoadAuthAudit(dir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return AuthAuditStore{Events: []AuthAuditEvent{}}, nil
}
return AuthAuditStore{}, err
}
return store, nil
}
// SaveAuthAudit writes auth-audit.json to dir with mode 0640.
func SaveAuthAudit(dir string, store AuthAuditStore) error {
if err := ensureConfigDir(dir); err != nil {
return err
}
if store.Events == nil {
store.Events = []AuthAuditEvent{}
}
payload, err := json.MarshalIndent(store, "", " ")
if err != nil {
return fmt.Errorf("encode auth audit: %w", err)
}
payload = append(payload, '\n')
path := filepath.Join(dir, AuthAuditFileName)
if err := os.WriteFile(path, payload, 0o640); err != nil {
return fmt.Errorf("write auth audit: %w", err)
}
return nil
}
// AppendAuthAuditEvent appends event and trims to MaxAuthAuditEvents (newest kept).
func AppendAuthAuditEvent(dir string, event AuthAuditEvent) error {
store, err := LoadAuthAuditOrEmpty(dir)
if err != nil {
return err
}
store.Events = append(store.Events, event)
if len(store.Events) > MaxAuthAuditEvents {
store.Events = store.Events[len(store.Events)-MaxAuthAuditEvents:]
}
return SaveAuthAudit(dir, store)
}
@@ -0,0 +1,81 @@
package settings
import (
"testing"
"time"
)
func TestAuthAuditRoundTripAndCap(t *testing.T) {
dir := t.TempDir()
store, err := LoadAuthAuditOrEmpty(dir)
if err != nil {
t.Fatalf("LoadAuthAuditOrEmpty: %v", err)
}
if len(store.Events) != 0 {
t.Fatalf("expected empty, got %d", len(store.Events))
}
event := AuthAuditEvent{
ID: "evt-1",
At: time.Now().UTC(),
Action: "login",
Outcome: AuthAuditOutcomeSuccess,
Category: AuthAuditCategoryAuth,
Actor: "admin",
}
if err := AppendAuthAuditEvent(dir, event); err != nil {
t.Fatalf("AppendAuthAuditEvent: %v", err)
}
loaded, err := LoadAuthAudit(dir)
if err != nil {
t.Fatalf("LoadAuthAudit: %v", err)
}
if len(loaded.Events) != 1 || loaded.Events[0].Action != "login" {
t.Fatalf("loaded = %+v", loaded)
}
if loaded.Events[0].Outcome != AuthAuditOutcomeSuccess {
t.Fatalf("outcome = %q", loaded.Events[0].Outcome)
}
}
func TestAppendAuthAuditEventTrimsToCap(t *testing.T) {
dir := t.TempDir()
originalCap := MaxAuthAuditEvents
store := AuthAuditStore{Events: make([]AuthAuditEvent, 0, MaxAuthAuditEvents+5)}
for index := 0; index < MaxAuthAuditEvents+5; index++ {
store.Events = append(store.Events, AuthAuditEvent{
ID: "evt",
At: time.Now().UTC(),
Action: "login",
Outcome: AuthAuditOutcomeSuccess,
Category: AuthAuditCategoryAuth,
Actor: "admin",
Detail: string(rune('a' + (index % 26))),
})
}
if err := SaveAuthAudit(dir, store); err != nil {
t.Fatalf("SaveAuthAudit: %v", err)
}
if err := AppendAuthAuditEvent(dir, AuthAuditEvent{
ID: "newest",
At: time.Now().UTC(),
Action: "logout",
Outcome: AuthAuditOutcomeSuccess,
Category: AuthAuditCategoryAuth,
Actor: "admin",
}); err != nil {
t.Fatalf("AppendAuthAuditEvent: %v", err)
}
loaded, err := LoadAuthAudit(dir)
if err != nil {
t.Fatalf("LoadAuthAudit: %v", err)
}
if len(loaded.Events) != originalCap {
t.Fatalf("len = %d, want %d", len(loaded.Events), originalCap)
}
if loaded.Events[len(loaded.Events)-1].ID != "newest" {
t.Fatalf("last id = %q", loaded.Events[len(loaded.Events)-1].ID)
}
}
+59
View File
@@ -0,0 +1,59 @@
package settings
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"fmt"
"io"
)
func seal(plaintext []byte, key []byte) (nonce []byte, ciphertext []byte, err error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, nil, fmt.Errorf("aes cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, nil, fmt.Errorf("gcm: %w", err)
}
nonce = make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, nil, fmt.Errorf("nonce: %w", err)
}
ciphertext = gcm.Seal(nil, nonce, plaintext, nil)
return nonce, ciphertext, nil
}
func open(nonce []byte, ciphertext []byte, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("aes cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("gcm: %w", err)
}
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, fmt.Errorf("decrypt: %w", err)
}
return plaintext, nil
}
func decodeKey(base64Key string) ([]byte, error) {
key, err := base64.StdEncoding.DecodeString(base64Key)
if err != nil {
return nil, fmt.Errorf("decode key: %w", err)
}
if length := len(key); length != 16 && length != 24 && length != 32 {
return nil, fmt.Errorf("key must be 16, 24, or 32 bytes after base64 decode, got %d", length)
}
return key, nil
}
+78
View File
@@ -0,0 +1,78 @@
package settings
import (
"encoding/base64"
"encoding/json"
"fmt"
"os"
"path/filepath"
)
type encryptedBlob struct {
Nonce string `json:"nonce"`
Ciphertext string `json:"ciphertext"`
}
func loadEncryptedJSON(dir string, fileName string, key []byte, destination any) error {
path := filepath.Join(dir, fileName)
payload, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read %s: %w", fileName, err)
}
var blob encryptedBlob
if err := json.Unmarshal(payload, &blob); err != nil {
return fmt.Errorf("parse %s envelope: %w", fileName, err)
}
nonce, err := base64.StdEncoding.DecodeString(blob.Nonce)
if err != nil {
return fmt.Errorf("decode nonce: %w", err)
}
ciphertext, err := base64.StdEncoding.DecodeString(blob.Ciphertext)
if err != nil {
return fmt.Errorf("decode ciphertext: %w", err)
}
plaintext, err := open(nonce, ciphertext, key)
if err != nil {
return err
}
if err := json.Unmarshal(plaintext, destination); err != nil {
return fmt.Errorf("parse %s: %w", fileName, err)
}
return nil
}
func saveEncryptedJSON(dir string, fileName string, key []byte, value any) error {
if err := ensureConfigDir(dir); err != nil {
return err
}
plaintext, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("encode %s: %w", fileName, err)
}
nonce, ciphertext, err := seal(plaintext, key)
if err != nil {
return err
}
blob := encryptedBlob{
Nonce: base64.StdEncoding.EncodeToString(nonce),
Ciphertext: base64.StdEncoding.EncodeToString(ciphertext),
}
payload, err := json.MarshalIndent(blob, "", " ")
if err != nil {
return fmt.Errorf("encode %s envelope: %w", fileName, err)
}
payload = append(payload, '\n')
path := filepath.Join(dir, fileName)
if err := os.WriteFile(path, payload, 0o600); err != nil {
return fmt.Errorf("write %s: %w", fileName, err)
}
return nil
}
+28
View File
@@ -0,0 +1,28 @@
package settings
import (
"net"
"path/filepath"
)
// ListenHost returns the configured listen IP for binding, defaulting to 0.0.0.0.
func ListenHost(dir string) string {
payload, err := LoadSettings(dir)
if err != nil {
return "0.0.0.0"
}
network := EffectiveNetwork(payload.Network)
host := network.ListenAddress
if host == "" {
return "0.0.0.0"
}
if host != "0.0.0.0" && host != "::" && host != "localhost" && net.ParseIP(host) == nil {
return "0.0.0.0"
}
return host
}
// SettingsPath returns the path to settings.json in dir.
func SettingsPath(dir string) string {
return filepath.Join(dir, SettingsFileName)
}
@@ -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)
}
+77
View File
@@ -0,0 +1,77 @@
package settings
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
)
// MaxNodeAuditEvents is the retention cap for node-audit.json.
const MaxNodeAuditEvents = 1000
// LoadNodeAudit reads and parses node-audit.json from dir.
func LoadNodeAudit(dir string) (NodeAuditStore, error) {
path := filepath.Join(dir, NodeAuditFileName)
payload, err := os.ReadFile(path)
if err != nil {
return NodeAuditStore{}, err
}
var store NodeAuditStore
if err := json.Unmarshal(payload, &store); err != nil {
return NodeAuditStore{}, fmt.Errorf("parse node audit: %w", err)
}
if store.Events == nil {
store.Events = []NodeAuditEvent{}
}
return store, nil
}
// LoadNodeAuditOrEmpty returns an empty store when node-audit.json is missing.
func LoadNodeAuditOrEmpty(dir string) (NodeAuditStore, error) {
store, err := LoadNodeAudit(dir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return NodeAuditStore{Events: []NodeAuditEvent{}}, nil
}
return NodeAuditStore{}, err
}
return store, nil
}
// SaveNodeAudit writes node-audit.json to dir with mode 0640.
func SaveNodeAudit(dir string, store NodeAuditStore) error {
if err := ensureConfigDir(dir); err != nil {
return err
}
if store.Events == nil {
store.Events = []NodeAuditEvent{}
}
payload, err := json.MarshalIndent(store, "", " ")
if err != nil {
return fmt.Errorf("encode node audit: %w", err)
}
payload = append(payload, '\n')
path := filepath.Join(dir, NodeAuditFileName)
if err := os.WriteFile(path, payload, 0o640); err != nil {
return fmt.Errorf("write node audit: %w", err)
}
return nil
}
// AppendNodeAuditEvent appends event and trims to MaxNodeAuditEvents (newest kept).
func AppendNodeAuditEvent(dir string, event NodeAuditEvent) error {
store, err := LoadNodeAuditOrEmpty(dir)
if err != nil {
return err
}
store.Events = append(store.Events, event)
if len(store.Events) > MaxNodeAuditEvents {
store.Events = store.Events[len(store.Events)-MaxNodeAuditEvents:]
}
return SaveNodeAudit(dir, store)
}
@@ -0,0 +1,82 @@
package settings
import (
"testing"
"time"
)
func TestNodeAuditRoundTripAndCap(t *testing.T) {
dir := t.TempDir()
store, err := LoadNodeAuditOrEmpty(dir)
if err != nil {
t.Fatalf("LoadNodeAuditOrEmpty: %v", err)
}
if len(store.Events) != 0 {
t.Fatalf("expected empty, got %d", len(store.Events))
}
event := NodeAuditEvent{
ID: "evt-1",
At: time.Now().UTC(),
Action: NodeAuditActionCreate,
Actor: "admin",
NodeID: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
NodeName: "ct1",
NodeKind: NodeKindContainer,
}
if err := AppendNodeAuditEvent(dir, event); err != nil {
t.Fatalf("AppendNodeAuditEvent: %v", err)
}
loaded, err := LoadNodeAudit(dir)
if err != nil {
t.Fatalf("LoadNodeAudit: %v", err)
}
if len(loaded.Events) != 1 || loaded.Events[0].Action != NodeAuditActionCreate {
t.Fatalf("loaded = %+v", loaded)
}
}
func TestAppendNodeAuditEventTrimsToCap(t *testing.T) {
dir := t.TempDir()
originalCap := MaxNodeAuditEvents
// Use a small synthetic store by appending more than the cap.
store := NodeAuditStore{Events: make([]NodeAuditEvent, 0, MaxNodeAuditEvents+5)}
for index := 0; index < MaxNodeAuditEvents+5; index++ {
store.Events = append(store.Events, NodeAuditEvent{
ID: "evt",
At: time.Now().UTC(),
Action: NodeAuditActionCreate,
Actor: "admin",
NodeID: "id",
NodeName: "n",
NodeKind: NodeKindVM,
Detail: string(rune('a' + (index % 26))),
})
}
if err := SaveNodeAudit(dir, store); err != nil {
t.Fatalf("SaveNodeAudit: %v", err)
}
if err := AppendNodeAuditEvent(dir, NodeAuditEvent{
ID: "newest",
At: time.Now().UTC(),
Action: NodeAuditActionDelete,
Actor: "admin",
NodeID: "id",
NodeName: "n",
NodeKind: NodeKindVM,
}); err != nil {
t.Fatalf("AppendNodeAuditEvent: %v", err)
}
loaded, err := LoadNodeAudit(dir)
if err != nil {
t.Fatalf("LoadNodeAudit: %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)
}
}
+93
View File
@@ -0,0 +1,93 @@
package settings
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
)
// LoadNodes reads and parses nodes.json from dir.
func LoadNodes(dir string) (NodeStore, error) {
path := filepath.Join(dir, NodesFileName)
payload, err := os.ReadFile(path)
if err != nil {
return NodeStore{}, err
}
var store NodeStore
if err := json.Unmarshal(payload, &store); err != nil {
return NodeStore{}, fmt.Errorf("parse nodes: %w", err)
}
if store.Nodes == nil {
store.Nodes = []Node{}
}
return store, nil
}
// LoadNodesOrEmpty returns an empty store when nodes.json is missing.
func LoadNodesOrEmpty(dir string) (NodeStore, error) {
store, err := LoadNodes(dir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return NodeStore{Nodes: []Node{}}, nil
}
return NodeStore{}, err
}
return store, nil
}
// SaveNodes writes nodes.json to dir with mode 0640.
func SaveNodes(dir string, store NodeStore) error {
if err := ensureConfigDir(dir); err != nil {
return err
}
if store.Nodes == nil {
store.Nodes = []Node{}
}
payload, err := json.MarshalIndent(store, "", " ")
if err != nil {
return fmt.Errorf("encode nodes: %w", err)
}
payload = append(payload, '\n')
path := filepath.Join(dir, NodesFileName)
if err := os.WriteFile(path, payload, 0o640); err != nil {
return fmt.Errorf("write nodes: %w", err)
}
return nil
}
// LoadNodeKeys decrypts node-keys.enc from dir using key.
func LoadNodeKeys(dir string, key []byte) (NodeKeyStore, error) {
var store NodeKeyStore
if err := loadEncryptedJSON(dir, NodeKeysFileName, key, &store); err != nil {
return NodeKeyStore{}, err
}
if store.Keys == nil {
store.Keys = []NodeKeyEntry{}
}
return store, nil
}
// LoadNodeKeysOrEmpty returns an empty store when node-keys.enc is missing.
func LoadNodeKeysOrEmpty(dir string, key []byte) (NodeKeyStore, error) {
store, err := LoadNodeKeys(dir, key)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return NodeKeyStore{Keys: []NodeKeyEntry{}}, nil
}
return NodeKeyStore{}, err
}
return store, nil
}
// SaveNodeKeys encrypts the node key store and writes node-keys.enc.
func SaveNodeKeys(dir string, store NodeKeyStore, key []byte) error {
if store.Keys == nil {
store.Keys = []NodeKeyEntry{}
}
return saveEncryptedJSON(dir, NodeKeysFileName, key, store)
}
+73
View File
@@ -0,0 +1,73 @@
package settings
import (
"encoding/base64"
"testing"
"time"
)
func TestNodesAndNodeKeysRoundTrip(t *testing.T) {
dir := t.TempDir()
keyBytes := make([]byte, 32)
for index := range keyBytes {
keyBytes[index] = byte(index + 3)
}
t.Setenv(ConfigKeyEnvVar, base64.StdEncoding.EncodeToString(keyBytes))
store := NodeStore{
Nodes: []Node{
{
ID: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
Kind: NodeKindContainer,
Name: "ct1",
HostIP: "127.0.0.1",
Username: "clustercanvas",
GroupName: AdministratorsGroupName,
PublicKey: "ssh-ed25519 AAAA",
KeyAlgo: "ed25519",
CreatedAt: time.Now().UTC(),
},
},
}
if err := SaveNodes(dir, store); err != nil {
t.Fatalf("SaveNodes: %v", err)
}
loaded, err := LoadNodes(dir)
if err != nil {
t.Fatalf("LoadNodes: %v", err)
}
if len(loaded.Nodes) != 1 || loaded.Nodes[0].Name != "ct1" {
t.Fatalf("loaded = %+v", loaded)
}
keyStore := NodeKeyStore{
Keys: []NodeKeyEntry{
{
NodeID: loaded.Nodes[0].ID,
PrivateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n-----END OPENSSH PRIVATE KEY-----\n",
Algorithm: "ed25519",
KDFRounds: 100,
},
},
}
if err := SaveNodeKeys(dir, keyStore, keyBytes); err != nil {
t.Fatalf("SaveNodeKeys: %v", err)
}
loadedKeys, err := LoadNodeKeys(dir, keyBytes)
if err != nil {
t.Fatalf("LoadNodeKeys: %v", err)
}
if len(loadedKeys.Keys) != 1 || loadedKeys.Keys[0].KDFRounds != 100 {
t.Fatalf("keys = %+v", loadedKeys)
}
}
func TestLoadNodesOrEmptyMissing(t *testing.T) {
store, err := LoadNodesOrEmpty(t.TempDir())
if err != nil {
t.Fatalf("LoadNodesOrEmpty: %v", err)
}
if len(store.Nodes) != 0 {
t.Fatalf("expected empty, got %d", len(store.Nodes))
}
}
+73
View File
@@ -0,0 +1,73 @@
package settings
import (
"fmt"
"os"
"path/filepath"
"strings"
)
const (
DefaultConfigDir = "/etc/ClusterCanvas"
ConfigDirEnvVar = "CLUSTERCANVAS_CONFIG_DIR"
ConfigKeyEnvVar = "CLUSTERCANVAS_CONFIG_KEY"
SettingsFileName = "settings.json"
SecretsFileName = "secrets.enc"
PasswordsFileName = "passwords.enc"
SessionsFileName = "sessions.enc"
NodesFileName = "nodes.json"
NodeKeysFileName = "node-keys.enc"
NodeAuditFileName = "node-audit.json"
AuthAuditFileName = "auth-audit.json"
ActionsFileName = "actions.json"
NodeActionGroupsFileName = "node-action-groups.json"
ActionLogsDirName = "action-logs"
)
// ResolveDir returns the config directory using precedence:
// 1. flagValue (-configdir), if non-empty
// 2. CLUSTERCANVAS_CONFIG_DIR, if set
// 3. /etc/ClusterCanvas
//
// Leading ~/ is expanded to the user home directory.
func ResolveDir(flagValue string) (string, error) {
candidate := strings.TrimSpace(flagValue)
if candidate == "" {
candidate = strings.TrimSpace(os.Getenv(ConfigDirEnvVar))
}
if candidate == "" {
candidate = DefaultConfigDir
}
expanded, err := expandHome(candidate)
if err != nil {
return "", err
}
return filepath.Clean(expanded), nil
}
func expandHome(path string) (string, error) {
if path == "~" {
homeDirectory, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("expand home directory: %w", err)
}
return homeDirectory, nil
}
if strings.HasPrefix(path, "~/") {
homeDirectory, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("expand home directory: %w", err)
}
return filepath.Join(homeDirectory, path[2:]), nil
}
return path, nil
}
func ensureConfigDir(dir string) error {
if err := os.MkdirAll(dir, 0o750); err != nil {
return fmt.Errorf("create config dir %q: %w", dir, err)
}
return nil
}
+94
View File
@@ -0,0 +1,94 @@
package settings
import (
"errors"
"fmt"
"os"
)
// KeyFromEnv reads and decodes CLUSTERCANVAS_CONFIG_KEY (base64, 16/24/32 bytes).
func KeyFromEnv() ([]byte, error) {
rawKey := os.Getenv(ConfigKeyEnvVar)
if rawKey == "" {
return nil, fmt.Errorf("%s is not set", ConfigKeyEnvVar)
}
return decodeKey(rawKey)
}
// LoadSecrets decrypts secrets.enc from dir using key.
func LoadSecrets(dir string, key []byte) (Secrets, error) {
var secrets Secrets
if err := loadEncryptedJSON(dir, SecretsFileName, key, &secrets); err != nil {
return Secrets{}, err
}
return secrets, nil
}
// SaveSecrets encrypts secrets and writes secrets.enc to dir with mode 0600.
func SaveSecrets(dir string, secrets Secrets, key []byte) error {
return saveEncryptedJSON(dir, SecretsFileName, key, secrets)
}
// LoadPasswords decrypts passwords.enc from dir using key.
func LoadPasswords(dir string, key []byte) (PasswordStore, error) {
var store PasswordStore
if err := loadEncryptedJSON(dir, PasswordsFileName, key, &store); err != nil {
return PasswordStore{}, err
}
if store.Users == nil {
store.Users = []UserCredential{}
}
return store, nil
}
// LoadPasswordsOrEmpty returns an empty store when passwords.enc is missing.
func LoadPasswordsOrEmpty(dir string, key []byte) (PasswordStore, error) {
store, err := LoadPasswords(dir, key)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return PasswordStore{Users: []UserCredential{}}, nil
}
return PasswordStore{}, err
}
return store, nil
}
// SavePasswords encrypts the password store and writes passwords.enc.
func SavePasswords(dir string, store PasswordStore, key []byte) error {
if store.Users == nil {
store.Users = []UserCredential{}
}
return saveEncryptedJSON(dir, PasswordsFileName, key, store)
}
// LoadSessions decrypts sessions.enc from dir using key.
func LoadSessions(dir string, key []byte) (SessionStore, error) {
var store SessionStore
if err := loadEncryptedJSON(dir, SessionsFileName, key, &store); err != nil {
return SessionStore{}, err
}
if store.Sessions == nil {
store.Sessions = []SessionRecord{}
}
return store, nil
}
// LoadSessionsOrEmpty returns an empty store when sessions.enc is missing.
func LoadSessionsOrEmpty(dir string, key []byte) (SessionStore, error) {
store, err := LoadSessions(dir, key)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return SessionStore{Sessions: []SessionRecord{}}, nil
}
return SessionStore{}, err
}
return store, nil
}
// SaveSessions encrypts the session store and writes sessions.enc.
func SaveSessions(dir string, store SessionStore, key []byte) error {
if store.Sessions == nil {
store.Sessions = []SessionRecord{}
}
return saveEncryptedJSON(dir, SessionsFileName, key, store)
}
+42
View File
@@ -0,0 +1,42 @@
package settings
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
// LoadSettings reads and parses settings.json from dir.
func LoadSettings(dir string) (Settings, error) {
path := filepath.Join(dir, SettingsFileName)
payload, err := os.ReadFile(path)
if err != nil {
return Settings{}, fmt.Errorf("read settings: %w", err)
}
var settings Settings
if err := json.Unmarshal(payload, &settings); err != nil {
return Settings{}, fmt.Errorf("parse settings: %w", err)
}
return settings, nil
}
// SaveSettings writes settings.json to dir with mode 0640.
func SaveSettings(dir string, settings Settings) error {
if err := ensureConfigDir(dir); err != nil {
return err
}
payload, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return fmt.Errorf("encode settings: %w", err)
}
payload = append(payload, '\n')
path := filepath.Join(dir, SettingsFileName)
if err := os.WriteFile(path, payload, 0o640); err != nil {
return fmt.Errorf("write settings: %w", err)
}
return nil
}
+152
View File
@@ -0,0 +1,152 @@
package settings
import (
"encoding/base64"
"os"
"path/filepath"
"testing"
)
func TestResolveDirDefault(t *testing.T) {
t.Setenv(ConfigDirEnvVar, "")
resolved, err := ResolveDir("")
if err != nil {
t.Fatalf("ResolveDir: %v", err)
}
if resolved != DefaultConfigDir {
t.Fatalf("got %q, want %q", resolved, DefaultConfigDir)
}
}
func TestResolveDirFlagOverridesEnv(t *testing.T) {
tempDir := t.TempDir()
flagDir := filepath.Join(tempDir, "from-flag")
envDir := filepath.Join(tempDir, "from-env")
t.Setenv(ConfigDirEnvVar, envDir)
resolved, err := ResolveDir(flagDir)
if err != nil {
t.Fatalf("ResolveDir: %v", err)
}
if resolved != flagDir {
t.Fatalf("got %q, want flag dir %q", resolved, flagDir)
}
}
func TestResolveDirEnvOverridesDefault(t *testing.T) {
tempDir := t.TempDir()
envDir := filepath.Join(tempDir, "from-env")
t.Setenv(ConfigDirEnvVar, envDir)
resolved, err := ResolveDir("")
if err != nil {
t.Fatalf("ResolveDir: %v", err)
}
if resolved != envDir {
t.Fatalf("got %q, want env dir %q", resolved, envDir)
}
}
func TestResolveDirExpandsTilde(t *testing.T) {
homeDirectory, err := os.UserHomeDir()
if err != nil {
t.Fatalf("UserHomeDir: %v", err)
}
t.Setenv(ConfigDirEnvVar, "")
resolved, err := ResolveDir("~/myconfigs")
if err != nil {
t.Fatalf("ResolveDir: %v", err)
}
want := filepath.Join(homeDirectory, "myconfigs")
if resolved != want {
t.Fatalf("got %q, want %q", resolved, want)
}
}
func TestSettingsRoundTrip(t *testing.T) {
dir := t.TempDir()
original := Settings{LogLevel: "debug"}
if err := SaveSettings(dir, original); err != nil {
t.Fatalf("SaveSettings: %v", err)
}
loaded, err := LoadSettings(dir)
if err != nil {
t.Fatalf("LoadSettings: %v", err)
}
if loaded.LogLevel != original.LogLevel {
t.Fatalf("got log_level %q, want %q", loaded.LogLevel, original.LogLevel)
}
}
func testKey(t *testing.T) []byte {
t.Helper()
key := make([]byte, 32)
for index := range key {
key[index] = byte(index + 1)
}
return key
}
func TestSecretsRoundTrip(t *testing.T) {
dir := t.TempDir()
key := testKey(t)
original := Secrets{}
if err := SaveSecrets(dir, original, key); err != nil {
t.Fatalf("SaveSecrets: %v", err)
}
loaded, err := LoadSecrets(dir, key)
if err != nil {
t.Fatalf("LoadSecrets: %v", err)
}
_ = loaded
}
func TestSecretsWrongKeyFails(t *testing.T) {
dir := t.TempDir()
goodKey := testKey(t)
if err := SaveSecrets(dir, Secrets{}, goodKey); err != nil {
t.Fatalf("SaveSecrets: %v", err)
}
badKey := make([]byte, 32)
for index := range badKey {
badKey[index] = 0xff
}
if _, err := LoadSecrets(dir, badKey); err == nil {
t.Fatal("expected decrypt failure with wrong key")
}
}
func TestKeyFromEnvMissing(t *testing.T) {
t.Setenv(ConfigKeyEnvVar, "")
if _, err := KeyFromEnv(); err == nil {
t.Fatal("expected error when key env is unset")
}
}
func TestKeyFromEnvValid(t *testing.T) {
key := testKey(t)
t.Setenv(ConfigKeyEnvVar, base64.StdEncoding.EncodeToString(key))
decoded, err := KeyFromEnv()
if err != nil {
t.Fatalf("KeyFromEnv: %v", err)
}
if len(decoded) != 32 {
t.Fatalf("got key length %d, want 32", len(decoded))
}
}
func TestKeyFromEnvInvalidLength(t *testing.T) {
t.Setenv(ConfigKeyEnvVar, base64.StdEncoding.EncodeToString([]byte("short")))
if _, err := KeyFromEnv(); err == nil {
t.Fatal("expected error for invalid key length")
}
}
+35
View File
@@ -0,0 +1,35 @@
package settings
import (
"errors"
"os"
"strings"
)
// IsSetupCompleted reports whether first-run setup has finished.
func IsSetupCompleted(dir string) (bool, error) {
payload, err := LoadSettings(dir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
return false, err
}
return payload.SetupCompleted, nil
}
// EffectiveNetwork returns network settings with defaults applied.
func EffectiveNetwork(network NetworkSettings) NetworkSettings {
defaults := DefaultNetworkSettings()
if network.ListenAddress == "" {
network.ListenAddress = defaults.ListenAddress
}
network.PublicHostname = strings.TrimSpace(network.PublicHostname)
if network.AccessMode == "" {
network.AccessMode = defaults.AccessMode
}
if network.Rules == nil {
network.Rules = []string{}
}
return network
}
+385
View File
@@ -0,0 +1,385 @@
package settings
import "time"
// GroupScopeKind identifies what a group scopes to.
type GroupScopeKind string
const (
GroupScopeNode GroupScopeKind = "node"
GroupScopeGroup GroupScopeKind = "group"
)
// AdministratorsGroupName is the built-in admin group created at setup.
// At least one user must remain in this group; the group itself cannot be deleted.
const AdministratorsGroupName = "Administrators"
// Group configures a named permission set for a given scope target.
//
// Groups are persisted in settings.json and later used by authz middleware.
type Group struct {
Name string `json:"name"`
ScopeKind GroupScopeKind `json:"scope_kind"`
ScopeName string `json:"scope_name"`
Permissions []string `json:"permissions"`
}
// SecuritySettings holds session and re-auth policy for the admin UI / auth middleware.
type SecuritySettings struct {
IdleTimeoutMinutes int `json:"idle_timeout_minutes"`
SessionLifetimeHours int `json:"session_lifetime_hours"`
ReauthSensitiveActions bool `json:"reauth_sensitive_actions"`
ReauthGraceMinutes int `json:"reauth_grace_minutes"`
TotpEnabled bool `json:"totp_enabled"`
}
// DefaultSecuritySettings returns the built-in security defaults.
func DefaultSecuritySettings() SecuritySettings {
return SecuritySettings{
IdleTimeoutMinutes: 30,
SessionLifetimeHours: 24,
ReauthSensitiveActions: false,
ReauthGraceMinutes: 15,
TotpEnabled: false,
}
}
// AccessMode controls IP allow/deny lists.
type AccessMode string
const (
AccessModeOpen AccessMode = "open"
AccessModeWhitelist AccessMode = "whitelist"
AccessModeBlacklist AccessMode = "blacklist"
)
// NetworkSettings holds listen bind and access-control configuration.
type NetworkSettings struct {
ListenAddress string `json:"listen_address"`
PublicHostname string `json:"public_hostname"`
AccessMode AccessMode `json:"access_mode"`
Rules []string `json:"rules"`
}
// DefaultNetworkSettings returns built-in network defaults.
func DefaultNetworkSettings() NetworkSettings {
return NetworkSettings{
ListenAddress: "0.0.0.0",
PublicHostname: "",
AccessMode: AccessModeOpen,
Rules: []string{},
}
}
// Settings holds plain (non-secret) base/CLI configuration.
type Settings struct {
SetupCompleted bool `json:"setup_completed"`
LogLevel string `json:"log_level"`
Groups []Group `json:"groups"`
Security SecuritySettings `json:"security"`
Network NetworkSettings `json:"network"`
}
// Secrets holds sensitive configuration encrypted at rest.
type Secrets struct {
// Placeholder until Proxmox/API secrets exist.
}
// NodeKind identifies which left-nav category a managed host belongs to.
type NodeKind string
const (
NodeKindContainer NodeKind = "container"
NodeKindVM NodeKind = "vm"
NodeKindDocker NodeKind = "docker"
)
// Node is a remote host ClusterCanvas manages over SSH.
type Node struct {
ID string `json:"id"`
Kind NodeKind `json:"kind"`
Name string `json:"name"`
HostIP string `json:"host_ip"`
Username string `json:"username"`
GroupName string `json:"group_name"`
PublicKey string `json:"public_key"`
KeyAlgo string `json:"key_algo"`
CreatedAt time.Time `json:"created_at"`
// Health check configuration and last result (persisted).
HealthCheckIntervalSeconds int `json:"health_check_interval_seconds"`
HealthLastCheckedAt *time.Time `json:"health_last_checked_at,omitempty"`
HealthPingOK *bool `json:"health_ping_ok,omitempty"`
HealthSSHOK *bool `json:"health_ssh_ok,omitempty"`
HealthOK *bool `json:"health_ok,omitempty"`
HealthMessage string `json:"health_message,omitempty"`
}
// NodeStore is the plain JSON payload in nodes.json.
type NodeStore struct {
Nodes []Node `json:"nodes"`
}
// ActionKind identifies how an action body is interpreted when run on a node.
type ActionKind string
const (
ActionKindShell ActionKind = "shell"
ActionKindScript ActionKind = "script"
)
// ActionEnvVar is one environment variable applied before an action runs.
type ActionEnvVar struct {
Name string `json:"name"`
Value string `json:"value"`
}
// Action is a named shell command or script that can run on managed nodes.
type Action struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Kind ActionKind `json:"kind"`
Body string `json:"body"`
Env []ActionEnvVar `json:"env"`
RequiresSudo bool `json:"requires_sudo"`
Builtin bool `json:"builtin"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ActionStore is the plain JSON payload in actions.json.
type ActionStore struct {
Actions []Action `json:"actions"`
}
// ActionItemSource identifies whether a node action item is a library ref or local copy.
type ActionItemSource string
const (
ActionItemSourceLibrary ActionItemSource = "library"
ActionItemSourceLocal ActionItemSource = "local"
)
// ActionGroupScheduleKind identifies how an action group is scheduled.
type ActionGroupScheduleKind string
const (
ActionGroupScheduleInterval ActionGroupScheduleKind = "interval"
ActionGroupScheduleTimes ActionGroupScheduleKind = "times"
)
// ActionGroupTimeSpec is one clock time (and optional weekdays) for a times schedule.
type ActionGroupTimeSpec struct {
Time string `json:"time"` // "HH:MM" in server local time
DaysOfWeek []int `json:"days_of_week,omitempty"` // 0=Sun..6=Sat; empty = every day
}
// ActionGroupSchedule configures when an action group runs automatically.
type ActionGroupSchedule struct {
Enabled bool `json:"enabled"`
Kind ActionGroupScheduleKind `json:"kind"`
EverySeconds int `json:"every_seconds,omitempty"`
Times []ActionGroupTimeSpec `json:"times,omitempty"`
}
// NodeActionItem is one ordered action inside a node action group.
type NodeActionItem struct {
ID string `json:"id"`
Source ActionItemSource `json:"source"`
LibraryActionID string `json:"library_action_id,omitempty"`
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
Kind ActionKind `json:"kind,omitempty"`
Body string `json:"body,omitempty"`
Env []ActionEnvVar `json:"env,omitempty"`
RequiresSudo bool `json:"requires_sudo,omitempty"` // local items; library refs inherit from library
SetVariable string `json:"set_variable,omitempty"` // capture trimmed stdout into this run-scoped name
RunIf string `json:"run_if,omitempty"` // e.g. "if $UpdateCount >= 1"
}
// NodeActionGroup is a named ordered set of actions with one schedule on a node.
type NodeActionGroup struct {
ID string `json:"id"`
NodeID string `json:"node_id"`
Name string `json:"name"`
Schedule ActionGroupSchedule `json:"schedule"`
Items []NodeActionItem `json:"items"`
LastRunAt *time.Time `json:"last_run_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// NodeActionGroupStore is the plain JSON payload in node-action-groups.json.
type NodeActionGroupStore struct {
Groups []NodeActionGroup `json:"groups"`
}
// ActionRunTrigger identifies what started an action run.
type ActionRunTrigger string
const (
ActionRunTriggerManual ActionRunTrigger = "manual"
ActionRunTriggerSchedule ActionRunTrigger = "schedule"
)
// ActionItemRunResult is the captured output of one action within a run.
type ActionItemRunResult struct {
ItemID string `json:"item_id"`
ActionName string `json:"action_name"`
Source string `json:"source"`
Kind ActionKind `json:"kind,omitempty"`
RemoteCommand string `json:"remote_command,omitempty"`
SSHUsername string `json:"ssh_username,omitempty"`
ExitCode int `json:"exit_code"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
Error string `json:"error,omitempty"`
Skipped bool `json:"skipped,omitempty"`
SkipReason string `json:"skip_reason,omitempty"`
SetVariable string `json:"set_variable,omitempty"`
VariableValue string `json:"variable_value,omitempty"`
StartedAt time.Time `json:"started_at"`
FinishedAt time.Time `json:"finished_at"`
}
// ActionGroupRunRecord is one execution of a group (or a single-item manual run).
type ActionGroupRunRecord struct {
ID string `json:"id"`
NodeID string `json:"node_id"`
GroupID string `json:"group_id"`
GroupName string `json:"group_name"`
Trigger ActionRunTrigger `json:"trigger"`
Actor string `json:"actor,omitempty"`
StartedAt time.Time `json:"started_at"`
FinishedAt time.Time `json:"finished_at"`
Actions []ActionItemRunResult `json:"actions"`
}
// ActionLogFile is the JSON payload for one node/group log file on disk.
type ActionLogFile struct {
NodeID string `json:"node_id"`
GroupID string `json:"group_id"`
GroupName string `json:"group_name"`
Runs []ActionGroupRunRecord `json:"runs"`
}
// ActionLogFileInfo is metadata for listing log files without reading full content.
type ActionLogFileInfo struct {
Filename string `json:"filename"`
GroupName string `json:"group_name"`
GroupID string `json:"group_id,omitempty"`
ModTime time.Time `json:"mod_time"`
SizeBytes int64 `json:"size_bytes"`
}
// NodeKeyEntry holds private key material for one node, keyed by node UUID.
type NodeKeyEntry struct {
NodeID string `json:"node_id"`
PrivateKey string `json:"private_key"`
Passphrase string `json:"passphrase,omitempty"`
Algorithm string `json:"algorithm"`
RSABits int `json:"rsa_bits,omitempty"`
KDFRounds int `json:"kdf_rounds,omitempty"`
}
// NodeKeyStore is the plaintext JSON payload inside node-keys.enc.
type NodeKeyStore struct {
Keys []NodeKeyEntry `json:"keys"`
}
// NodeAuditAction identifies a recorded node mutation.
type NodeAuditAction string
const (
NodeAuditActionCreate NodeAuditAction = "create"
NodeAuditActionDelete NodeAuditAction = "delete"
NodeAuditActionTestSSH NodeAuditAction = "test_ssh"
NodeAuditActionHealthCheck NodeAuditAction = "health_check"
NodeAuditActionUpdate NodeAuditAction = "update"
)
// NodeAuditEvent is one append-only record of a node mutation.
type NodeAuditEvent struct {
ID string `json:"id"`
At time.Time `json:"at"`
Action NodeAuditAction `json:"action"`
Actor string `json:"actor"`
NodeID string `json:"node_id"`
NodeName string `json:"node_name"`
NodeKind NodeKind `json:"node_kind"`
Detail string `json:"detail,omitempty"`
}
// NodeAuditStore is the plain JSON payload in node-audit.json.
type NodeAuditStore struct {
Events []NodeAuditEvent `json:"events"`
}
// AuthAuditOutcome is success or failure for an auth/user/group audit event.
type AuthAuditOutcome string
const (
AuthAuditOutcomeSuccess AuthAuditOutcome = "success"
AuthAuditOutcomeFailure AuthAuditOutcome = "failure"
)
// AuthAuditCategory groups auth audit events for UI styling and filtering.
type AuthAuditCategory string
const (
AuthAuditCategoryAuth AuthAuditCategory = "auth"
AuthAuditCategoryUser AuthAuditCategory = "user"
AuthAuditCategoryGroup AuthAuditCategory = "group"
AuthAuditCategorySelf AuthAuditCategory = "self"
)
// AuthAuditEvent is one append-only record of a login or user/group change.
type AuthAuditEvent struct {
ID string `json:"id"`
At time.Time `json:"at"`
Action string `json:"action"`
Outcome AuthAuditOutcome `json:"outcome"`
Category AuthAuditCategory `json:"category"`
Actor string `json:"actor"`
Target string `json:"target,omitempty"`
Detail string `json:"detail,omitempty"`
}
// AuthAuditStore is the plain JSON payload in auth-audit.json.
type AuthAuditStore struct {
Events []AuthAuditEvent `json:"events"`
}
// UserCredential is one account stored in passwords.enc.
type UserCredential struct {
ID string `json:"id"`
Username string `json:"username"`
PasswordHash string `json:"password_hash"`
TOTPSecret string `json:"totp_secret"`
TOTPConfirmed bool `json:"totp_confirmed"`
Enabled bool `json:"enabled"`
GroupNames []string `json:"group_names"`
CreatedAt time.Time `json:"created_at"`
PasswordChangedAt time.Time `json:"password_changed_at"`
}
// PasswordStore is the plaintext JSON payload inside passwords.enc.
type PasswordStore struct {
Users []UserCredential `json:"users"`
}
// SessionRecord is one server-side session stored in sessions.enc.
type SessionRecord struct {
ID string `json:"id"`
UserID string `json:"user_id"`
CreatedAt time.Time `json:"created_at"`
LastSeenAt time.Time `json:"last_seen_at"`
LastReauthAt time.Time `json:"last_reauth_at"`
ExpiresAt time.Time `json:"expires_at"`
}
// SessionStore is the plaintext JSON payload inside sessions.enc.
type SessionStore struct {
Sessions []SessionRecord `json:"sessions"`
}
+118
View File
@@ -0,0 +1,118 @@
package websrv
import (
"fmt"
"net"
"net/http"
"net/http/httputil"
"net/url"
"os"
"path"
"path/filepath"
"strings"
)
// NewHandler serves SPA files from webDir and reverse-proxies /api and /health to apiURL.
func NewHandler(webDir string, apiURL string) (http.Handler, error) {
absoluteWebDir, err := filepath.Abs(webDir)
if err != nil {
return nil, fmt.Errorf("resolve webdir: %w", err)
}
info, err := os.Stat(absoluteWebDir)
if err != nil {
return nil, fmt.Errorf("webdir: %w", err)
}
if !info.IsDir() {
return nil, fmt.Errorf("webdir is not a directory: %s", absoluteWebDir)
}
parsedAPIURL, err := url.Parse(apiURL)
if err != nil {
return nil, fmt.Errorf("parse API_URL: %w", err)
}
if parsedAPIURL.Scheme == "" || parsedAPIURL.Host == "" {
return nil, fmt.Errorf("API_URL must include scheme and host: %q", apiURL)
}
apiProxy := httputil.NewSingleHostReverseProxy(parsedAPIURL)
defaultDirector := apiProxy.Director
apiProxy.Director = func(request *http.Request) {
originalHost := request.Host
clientIP := clientIPFromRequest(request)
defaultDirector(request)
// Preserve the browser-facing host so the API can enforce public_hostname.
if request.Header.Get("X-Forwarded-Host") == "" && originalHost != "" {
request.Header.Set("X-Forwarded-Host", originalHost)
}
if request.Header.Get("X-Forwarded-Proto") == "" {
if request.TLS != nil {
request.Header.Set("X-Forwarded-Proto", "https")
} else {
request.Header.Set("X-Forwarded-Proto", "http")
}
}
if clientIP != "" {
prior := strings.TrimSpace(request.Header.Get("X-Forwarded-For"))
if prior == "" {
request.Header.Set("X-Forwarded-For", clientIP)
} else {
request.Header.Set("X-Forwarded-For", prior+", "+clientIP)
}
}
}
fileServer := http.FileServer(http.Dir(absoluteWebDir))
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
requestPath := request.URL.Path
if requestPath == "/health" || strings.HasPrefix(requestPath, "/api/") || requestPath == "/api" {
apiProxy.ServeHTTP(writer, request)
return
}
serveSPA(writer, request, absoluteWebDir, fileServer)
}), nil
}
func clientIPFromRequest(request *http.Request) string {
host, _, err := net.SplitHostPort(request.RemoteAddr)
if err != nil {
return request.RemoteAddr
}
return host
}
func serveSPA(
writer http.ResponseWriter,
request *http.Request,
webDir string,
fileServer http.Handler,
) {
cleanedPath := path.Clean("/" + request.URL.Path)
if cleanedPath == "/" {
http.ServeFile(writer, request, filepath.Join(webDir, "index.html"))
return
}
candidatePath := filepath.Join(webDir, filepath.FromSlash(cleanedPath))
if !isWithinDir(webDir, candidatePath) {
http.NotFound(writer, request)
return
}
fileInfo, err := os.Stat(candidatePath)
if err != nil || fileInfo.IsDir() {
http.ServeFile(writer, request, filepath.Join(webDir, "index.html"))
return
}
fileServer.ServeHTTP(writer, request)
}
func isWithinDir(rootDir string, candidatePath string) bool {
relativePath, err := filepath.Rel(rootDir, candidatePath)
if err != nil {
return false
}
return relativePath != ".." && !strings.HasPrefix(relativePath, ".."+string(os.PathSeparator))
}
+128
View File
@@ -0,0 +1,128 @@
package websrv
import (
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestNewHandlerRejectsMissingWebDir(t *testing.T) {
t.Parallel()
_, err := NewHandler(filepath.Join(t.TempDir(), "missing"), "http://127.0.0.1:8080")
if err == nil {
t.Fatal("expected error for missing webdir")
}
}
func TestNewHandlerRejectsInvalidAPIURL(t *testing.T) {
t.Parallel()
webDir := t.TempDir()
_, err := NewHandler(webDir, "not-a-url")
if err == nil {
t.Fatal("expected error for invalid API_URL")
}
}
func TestSPAServesIndexAndAssets(t *testing.T) {
t.Parallel()
webDir := t.TempDir()
mustWriteFile(t, filepath.Join(webDir, "index.html"), "<html>home</html>")
mustWriteFile(t, filepath.Join(webDir, "assets", "app.js"), "console.log(1)")
api := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(api.Close)
handler, err := NewHandler(webDir, api.URL)
if err != nil {
t.Fatalf("NewHandler: %v", err)
}
indexRecorder := httptest.NewRecorder()
handler.ServeHTTP(indexRecorder, httptest.NewRequest(http.MethodGet, "/", nil))
if indexRecorder.Code != http.StatusOK {
t.Fatalf("GET / status = %d", indexRecorder.Code)
}
if !strings.Contains(indexRecorder.Body.String(), "home") {
t.Fatalf("GET / body = %q", indexRecorder.Body.String())
}
assetRecorder := httptest.NewRecorder()
handler.ServeHTTP(assetRecorder, httptest.NewRequest(http.MethodGet, "/assets/app.js", nil))
if assetRecorder.Code != http.StatusOK {
t.Fatalf("GET /assets/app.js status = %d", assetRecorder.Code)
}
if assetRecorder.Body.String() != "console.log(1)" {
t.Fatalf("asset body = %q", assetRecorder.Body.String())
}
spaRecorder := httptest.NewRecorder()
handler.ServeHTTP(spaRecorder, httptest.NewRequest(http.MethodGet, "/settings", nil))
if spaRecorder.Code != http.StatusOK {
t.Fatalf("GET /settings status = %d", spaRecorder.Code)
}
if !strings.Contains(spaRecorder.Body.String(), "home") {
t.Fatalf("SPA fallback body = %q", spaRecorder.Body.String())
}
}
func TestProxyForwardsAPIAndHealth(t *testing.T) {
t.Parallel()
webDir := t.TempDir()
mustWriteFile(t, filepath.Join(webDir, "index.html"), "<html>home</html>")
var seenPaths []string
var seenForwardedHost string
api := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
seenPaths = append(seenPaths, request.URL.Path)
seenForwardedHost = request.Header.Get("X-Forwarded-Host")
writer.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(writer, `{"ok":true}`)
}))
t.Cleanup(api.Close)
handler, err := NewHandler(webDir, api.URL)
if err != nil {
t.Fatalf("NewHandler: %v", err)
}
healthRecorder := httptest.NewRecorder()
handler.ServeHTTP(healthRecorder, httptest.NewRequest(http.MethodGet, "/health", nil))
if healthRecorder.Code != http.StatusOK {
t.Fatalf("GET /health status = %d", healthRecorder.Code)
}
apiRequest := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
apiRequest.Host = "admin.example.test:5173"
apiRecorder := httptest.NewRecorder()
handler.ServeHTTP(apiRecorder, apiRequest)
if apiRecorder.Code != http.StatusOK {
t.Fatalf("GET /api/v1/status status = %d", apiRecorder.Code)
}
if len(seenPaths) != 2 || seenPaths[0] != "/health" || seenPaths[1] != "/api/v1/status" {
t.Fatalf("proxied paths = %#v", seenPaths)
}
if seenForwardedHost != "admin.example.test:5173" {
t.Fatalf("X-Forwarded-Host = %q, want browser host", seenForwardedHost)
}
}
func mustWriteFile(t *testing.T, path string, contents string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
}
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+59
View File
@@ -0,0 +1,59 @@
# ClusterCanvas web UI
React 19 + TypeScript + Vite SPA for ClusterCanvas. It talks to the Go API over `/api` and `/health`.
For service config, remotedev, and repo-wide build/test targets, see the [root README](../README.md).
## Develop
From this directory:
```bash
npm install
npm run dev
```
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.
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ClusterCanvas</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2696
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "webui",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"qrcode": "^1.5.4",
"react": "^19.2.7",
"react-dom": "^19.2.7"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.13.2",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"jsdom": "^29.1.1",
"oxlint": "^1.71.0",
"typescript": "~6.0.2",
"vite": "^8.1.1",
"vitest": "^4.1.10"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+1903
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+4284
View File
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More