Compare commits
24
Commits
main
..
d188ca41b8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d188ca41b8 | ||
|
|
e047499fed | ||
|
|
aac9e82766 | ||
|
|
5307ba1a7b | ||
|
|
7631591f30 | ||
|
|
0e06063c1d | ||
|
|
eade2ef0c7 | ||
|
|
32af91fd30 | ||
|
|
50568127c5 | ||
|
|
463aa9a7a3 | ||
|
|
e3793f380c | ||
|
|
c90a47c3ea | ||
|
|
77be394407 | ||
|
|
f4dc8f63d7 | ||
|
|
b93b7519ec | ||
|
|
5d6f65f3cd | ||
|
|
458f14e74f | ||
|
|
2605c3b346 | ||
|
|
f049f766d9 | ||
|
|
c8f47768a3 | ||
|
|
63deb02d39 | ||
|
|
324367abd7 | ||
|
|
d2b92401f1 | ||
|
|
38b4e8fd43 |
+31
@@ -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*
|
||||
@@ -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
|
||||
@@ -1,3 +1,144 @@
|
||||
# ClusterCanvas
|
||||
|
||||
A web-based dashboard for managing Proxmox VMs and LXC containers. After configuration it discovers hosts and shows per‑instance details like hostname, storage and RAM usage, and status. From the same interface you can view logs, run updates, deploy changes, and perform other routine maintenance tasks.
|
||||
A web UI and Go API for managing remote hosts over SSH. You register hosts manually (grouped as containers, VMs, or Docker for navigation), run shell or script actions on them—including scheduled action groups—and administer users, groups, and sessions with a first-run setup wizard, optional TOTP, and audit logs.
|
||||
|
||||
## Features
|
||||
|
||||
- **SSH hosts** — Register nodes with generated or imported keys, test connectivity, and organize them under Containers / VMs / Docker categories
|
||||
- **Action library** — Global shell/script actions (seeded builtins: Uptime, Disk usage, System info) plus custom actions with env vars and optional sudo
|
||||
- **Action groups** — Per-node ordered items (library refs or local copies), schedules (interval or clock times), `set_variable` / `run_if`, and run logs
|
||||
- **Auth & access** — Setup wizard, session cookies, group permissions, optional TOTP, and reauth for sensitive operations
|
||||
- **Activity** — Node and auth audit trails, plus per-node action run history
|
||||
|
||||
## Layout
|
||||
|
||||
| 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/`.
|
||||
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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,902 @@
|
||||
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"`
|
||||
WidgetLabel string `json:"widget_label"`
|
||||
}
|
||||
|
||||
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"`
|
||||
WidgetLabel *string `json:"widget_label"`
|
||||
}
|
||||
|
||||
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.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
|
||||
}
|
||||
} else if payload.Env != nil {
|
||||
env := *payload.Env
|
||||
if env == nil {
|
||||
env = []settings.ActionEnvVar{}
|
||||
}
|
||||
for index := range env {
|
||||
env[index].Name = strings.TrimSpace(env[index].Name)
|
||||
if env[index].Name == "" || !envVarNamePattern.MatchString(env[index].Name) {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "environment variable name must match [A-Za-z_][A-Za-z0-9_]*"})
|
||||
return
|
||||
}
|
||||
}
|
||||
item.Env = env
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if payload.WidgetLabel != nil {
|
||||
item.WidgetLabel = strings.TrimSpace(*payload.WidgetLabel)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
_ = settings.DeleteActionWidgetsForItem(app.ConfigDir, node.ID, itemID)
|
||||
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")
|
||||
}
|
||||
env := payload.Env
|
||||
if env == nil {
|
||||
env = []settings.ActionEnvVar{}
|
||||
}
|
||||
for index := range env {
|
||||
env[index].Name = strings.TrimSpace(env[index].Name)
|
||||
if env[index].Name == "" || !envVarNamePattern.MatchString(env[index].Name) {
|
||||
return settings.NodeActionItem{}, errors.New("environment variable name must match [A-Za-z_][A-Za-z0-9_]*")
|
||||
}
|
||||
}
|
||||
return settings.NodeActionItem{
|
||||
ID: itemID,
|
||||
Source: settings.ActionItemSourceLibrary,
|
||||
LibraryActionID: libraryID,
|
||||
Env: env,
|
||||
SetVariable: setVariable,
|
||||
RunIf: runIf,
|
||||
WidgetLabel: strings.TrimSpace(payload.WidgetLabel),
|
||||
}, 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,
|
||||
WidgetLabel: strings.TrimSpace(payload.WidgetLabel),
|
||||
}, nil
|
||||
default:
|
||||
return settings.NodeActionItem{}, errors.New("source must be library or local")
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeSetVariable(raw string) (string, error) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return "", nil
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "$") {
|
||||
trimmed = strings.TrimPrefix(trimmed, "$")
|
||||
trimmed = strings.TrimSpace(trimmed)
|
||||
}
|
||||
if err := runner.ValidateVariableName(trimmed); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return trimmed, nil
|
||||
}
|
||||
|
||||
func normalizeRunIf(raw string) (string, error) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return "", nil
|
||||
}
|
||||
if err := runner.ValidateCondition(trimmed); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return trimmed, nil
|
||||
}
|
||||
|
||||
func validateSchedule(schedule settings.ActionGroupSchedule) error {
|
||||
switch schedule.Kind {
|
||||
case settings.ActionGroupScheduleInterval:
|
||||
if schedule.Enabled && schedule.EverySeconds < 1 {
|
||||
return errors.New("every_seconds must be at least 1 when interval schedule is enabled")
|
||||
}
|
||||
return nil
|
||||
case settings.ActionGroupScheduleTimes:
|
||||
if schedule.Enabled && len(schedule.Times) == 0 {
|
||||
return errors.New("at least one time is required when times schedule is enabled")
|
||||
}
|
||||
for _, spec := range schedule.Times {
|
||||
hour, minute, ok := parseScheduleHHMM(spec.Time)
|
||||
if !ok {
|
||||
return errors.New("time must be HH:MM")
|
||||
}
|
||||
_ = hour
|
||||
_ = minute
|
||||
for _, day := range spec.DaysOfWeek {
|
||||
if day < 0 || day > 6 {
|
||||
return errors.New("days_of_week values must be 0-6")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case "":
|
||||
return errors.New("schedule kind is required")
|
||||
default:
|
||||
return errors.New("schedule kind must be interval or times")
|
||||
}
|
||||
}
|
||||
|
||||
func parseScheduleHHMM(value string) (hour int, minute int, ok bool) {
|
||||
parts := strings.Split(strings.TrimSpace(value), ":")
|
||||
if len(parts) != 2 {
|
||||
return 0, 0, false
|
||||
}
|
||||
if _, err := fmtSscanf(parts[0], &hour); err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
if _, err := fmtSscanf(parts[1], &minute); err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
if hour < 0 || hour > 23 || minute < 0 || minute > 59 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return hour, minute, true
|
||||
}
|
||||
|
||||
func fmtSscanf(input string, dest *int) (int, error) {
|
||||
n := 0
|
||||
for _, ch := range input {
|
||||
if ch < '0' || ch > '9' {
|
||||
return 0, errors.New("not a number")
|
||||
}
|
||||
n = n*10 + int(ch-'0')
|
||||
}
|
||||
if input == "" {
|
||||
return 0, errors.New("empty")
|
||||
}
|
||||
*dest = n
|
||||
return 1, nil
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||
)
|
||||
|
||||
func seedNodeForActions(t *testing.T, configDir string) settings.Node {
|
||||
t.Helper()
|
||||
node := settings.Node{
|
||||
ID: "11111111-1111-4111-8111-111111111111",
|
||||
Kind: settings.NodeKindContainer,
|
||||
Name: "box-1",
|
||||
HostIP: "127.0.0.1",
|
||||
Username: "root",
|
||||
GroupName: settings.AdministratorsGroupName,
|
||||
PublicKey: "ssh-ed25519 AAAA",
|
||||
KeyAlgo: "ed25519",
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if err := settings.SaveNodes(configDir, settings.NodeStore{Nodes: []settings.Node{node}}); err != nil {
|
||||
t.Fatalf("SaveNodes: %v", err)
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
func TestActionGroupsCRUDCloneReorderAndAuthz(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
node := seedNodeForActions(t, configDir)
|
||||
if _, err := settings.LoadActionsOrSeed(configDir); err != nil {
|
||||
t.Fatalf("LoadActionsOrSeed: %v", err)
|
||||
}
|
||||
|
||||
router := NewRouter(configDir)
|
||||
|
||||
createBody, _ := json.Marshal(map[string]any{
|
||||
"name": "Nightly",
|
||||
"schedule": map[string]any{
|
||||
"enabled": false,
|
||||
"kind": "interval",
|
||||
"every_seconds": 3600,
|
||||
"times": []any{},
|
||||
},
|
||||
})
|
||||
createReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/nodes/"+node.ID+"/action-groups",
|
||||
bytes.NewReader(createBody),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
createRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(createRec, createReq)
|
||||
if createRec.Code != http.StatusOK {
|
||||
t.Fatalf("create group status=%d body=%s", createRec.Code, createRec.Body.String())
|
||||
}
|
||||
var created actionGroupResponse
|
||||
if err := json.Unmarshal(createRec.Body.Bytes(), &created); err != nil {
|
||||
t.Fatalf("decode create: %v", err)
|
||||
}
|
||||
groupID := created.Group.ID
|
||||
|
||||
addLibBody, _ := json.Marshal(map[string]any{
|
||||
"source": "library",
|
||||
"library_action_id": settings.BuiltinActionUptimeID,
|
||||
})
|
||||
addReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items",
|
||||
bytes.NewReader(addLibBody),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
addRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(addRec, addReq)
|
||||
if addRec.Code != http.StatusOK {
|
||||
t.Fatalf("add item status=%d body=%s", addRec.Code, addRec.Body.String())
|
||||
}
|
||||
var withItem actionGroupResponse
|
||||
if err := json.Unmarshal(addRec.Body.Bytes(), &withItem); err != nil {
|
||||
t.Fatalf("decode add: %v", err)
|
||||
}
|
||||
if len(withItem.Group.Items) != 1 || withItem.Group.Items[0].Source != settings.ActionItemSourceLibrary {
|
||||
t.Fatalf("items = %#v", withItem.Group.Items)
|
||||
}
|
||||
itemID := withItem.Group.Items[0].ID
|
||||
|
||||
addCustomBody, _ := json.Marshal(map[string]any{
|
||||
"source": "local",
|
||||
"name": "Echo",
|
||||
"kind": "shell",
|
||||
"body": "echo hi",
|
||||
"env": []any{},
|
||||
})
|
||||
addCustomReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items",
|
||||
bytes.NewReader(addCustomBody),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
addCustomRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(addCustomRec, addCustomReq)
|
||||
if addCustomRec.Code != http.StatusOK {
|
||||
t.Fatalf("add custom status=%d body=%s", addCustomRec.Code, addCustomRec.Body.String())
|
||||
}
|
||||
var withTwo actionGroupResponse
|
||||
_ = json.Unmarshal(addCustomRec.Body.Bytes(), &withTwo)
|
||||
if len(withTwo.Group.Items) != 2 {
|
||||
t.Fatalf("expected 2 items, got %d", len(withTwo.Group.Items))
|
||||
}
|
||||
customID := withTwo.Group.Items[1].ID
|
||||
|
||||
orderBody, _ := json.Marshal(map[string]any{
|
||||
"item_ids": []string{customID, itemID},
|
||||
})
|
||||
orderReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items/order",
|
||||
bytes.NewReader(orderBody),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
orderRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(orderRec, orderReq)
|
||||
if orderRec.Code != http.StatusOK {
|
||||
t.Fatalf("reorder status=%d body=%s", orderRec.Code, orderRec.Body.String())
|
||||
}
|
||||
var reordered actionGroupResponse
|
||||
_ = json.Unmarshal(orderRec.Body.Bytes(), &reordered)
|
||||
if reordered.Group.Items[0].ID != customID || reordered.Group.Items[1].ID != itemID {
|
||||
t.Fatalf("order = %#v", reordered.Group.Items)
|
||||
}
|
||||
|
||||
cloneReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items/"+itemID+"/clone",
|
||||
nil,
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
cloneRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(cloneRec, cloneReq)
|
||||
if cloneRec.Code != http.StatusOK {
|
||||
t.Fatalf("clone status=%d body=%s", cloneRec.Code, cloneRec.Body.String())
|
||||
}
|
||||
var cloned actionGroupResponse
|
||||
_ = json.Unmarshal(cloneRec.Body.Bytes(), &cloned)
|
||||
var clonedItem settings.NodeActionItem
|
||||
for _, item := range cloned.Group.Items {
|
||||
if item.ID == itemID {
|
||||
clonedItem = item
|
||||
break
|
||||
}
|
||||
}
|
||||
if clonedItem.Source != settings.ActionItemSourceLocal || clonedItem.Name != "Uptime" {
|
||||
t.Fatalf("cloned item = %#v", clonedItem)
|
||||
}
|
||||
if clonedItem.RequiresSudo {
|
||||
t.Fatalf("builtin uptime clone should not require sudo: %#v", clonedItem)
|
||||
}
|
||||
|
||||
// Forbidden without actions.create for a limited user session is covered below via groups.
|
||||
listReq := withSession(
|
||||
httptest.NewRequest(http.MethodGet, "/api/v1/nodes/"+node.ID+"/action-groups", nil),
|
||||
cookie,
|
||||
)
|
||||
listRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(listRec, listReq)
|
||||
if listRec.Code != http.StatusOK {
|
||||
t.Fatalf("list status=%d", listRec.Code)
|
||||
}
|
||||
|
||||
logsReq := withSession(
|
||||
httptest.NewRequest(http.MethodGet, "/api/v1/nodes/"+node.ID+"/action-logs", nil),
|
||||
cookie,
|
||||
)
|
||||
logsRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(logsRec, logsReq)
|
||||
if logsRec.Code != http.StatusOK {
|
||||
t.Fatalf("logs list status=%d body=%s", logsRec.Code, logsRec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionGroupItemVariablesCreateAndPatch(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
node := seedNodeForActions(t, configDir)
|
||||
if _, err := settings.LoadActionsOrSeed(configDir); err != nil {
|
||||
t.Fatalf("LoadActionsOrSeed: %v", err)
|
||||
}
|
||||
router := NewRouter(configDir)
|
||||
|
||||
createBody, _ := json.Marshal(map[string]any{
|
||||
"name": "Vars",
|
||||
"schedule": map[string]any{
|
||||
"enabled": false,
|
||||
"kind": "interval",
|
||||
"times": []any{},
|
||||
},
|
||||
})
|
||||
createReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/nodes/"+node.ID+"/action-groups",
|
||||
bytes.NewReader(createBody),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
createRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(createRec, createReq)
|
||||
if createRec.Code != http.StatusOK {
|
||||
t.Fatalf("create group status=%d body=%s", createRec.Code, createRec.Body.String())
|
||||
}
|
||||
var created actionGroupResponse
|
||||
if err := json.Unmarshal(createRec.Body.Bytes(), &created); err != nil {
|
||||
t.Fatalf("decode create: %v", err)
|
||||
}
|
||||
groupID := created.Group.ID
|
||||
|
||||
addLocalBody, _ := json.Marshal(map[string]any{
|
||||
"source": "local",
|
||||
"name": "Count",
|
||||
"kind": "shell",
|
||||
"body": "echo 3",
|
||||
"env": []any{},
|
||||
"set_variable": "UpdateCount",
|
||||
"run_if": "",
|
||||
})
|
||||
addLocalReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items",
|
||||
bytes.NewReader(addLocalBody),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
addLocalRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(addLocalRec, addLocalReq)
|
||||
if addLocalRec.Code != http.StatusOK {
|
||||
t.Fatalf("add local status=%d body=%s", addLocalRec.Code, addLocalRec.Body.String())
|
||||
}
|
||||
var withLocal actionGroupResponse
|
||||
if err := json.Unmarshal(addLocalRec.Body.Bytes(), &withLocal); err != nil {
|
||||
t.Fatalf("decode local: %v", err)
|
||||
}
|
||||
if withLocal.Group.Items[0].SetVariable != "UpdateCount" {
|
||||
t.Fatalf("set_variable = %#v", withLocal.Group.Items[0])
|
||||
}
|
||||
localID := withLocal.Group.Items[0].ID
|
||||
|
||||
addLibBody, _ := json.Marshal(map[string]any{
|
||||
"source": "library",
|
||||
"library_action_id": settings.BuiltinActionUptimeID,
|
||||
"set_variable": "$Bad-Name",
|
||||
})
|
||||
badLibReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items",
|
||||
bytes.NewReader(addLibBody),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
badLibRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(badLibRec, badLibReq)
|
||||
if badLibRec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected bad variable name, got %d body=%s", badLibRec.Code, badLibRec.Body.String())
|
||||
}
|
||||
|
||||
addLibOKBody, _ := json.Marshal(map[string]any{
|
||||
"source": "library",
|
||||
"library_action_id": settings.BuiltinActionUptimeID,
|
||||
})
|
||||
addLibReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items",
|
||||
bytes.NewReader(addLibOKBody),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
addLibRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(addLibRec, addLibReq)
|
||||
if addLibRec.Code != http.StatusOK {
|
||||
t.Fatalf("add library status=%d body=%s", addLibRec.Code, addLibRec.Body.String())
|
||||
}
|
||||
var withLib actionGroupResponse
|
||||
_ = json.Unmarshal(addLibRec.Body.Bytes(), &withLib)
|
||||
var libID string
|
||||
for _, item := range withLib.Group.Items {
|
||||
if item.Source == settings.ActionItemSourceLibrary {
|
||||
libID = item.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if libID == "" {
|
||||
t.Fatal("library item missing")
|
||||
}
|
||||
|
||||
patchLibBody, _ := json.Marshal(map[string]any{
|
||||
"run_if": "if $UpdateCount >= 1",
|
||||
"set_variable": "UptimeOut",
|
||||
})
|
||||
patchLibReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPatch,
|
||||
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items/"+libID,
|
||||
bytes.NewReader(patchLibBody),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
patchLibRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(patchLibRec, patchLibReq)
|
||||
if patchLibRec.Code != http.StatusOK {
|
||||
t.Fatalf("patch library vars status=%d body=%s", patchLibRec.Code, patchLibRec.Body.String())
|
||||
}
|
||||
var patchedLib actionGroupResponse
|
||||
_ = json.Unmarshal(patchLibRec.Body.Bytes(), &patchedLib)
|
||||
var libItem settings.NodeActionItem
|
||||
for _, item := range patchedLib.Group.Items {
|
||||
if item.ID == libID {
|
||||
libItem = item
|
||||
break
|
||||
}
|
||||
}
|
||||
if libItem.RunIf != "if $UpdateCount >= 1" || libItem.SetVariable != "UptimeOut" {
|
||||
t.Fatalf("library vars = %#v", libItem)
|
||||
}
|
||||
|
||||
badBodyPatch, _ := json.Marshal(map[string]any{
|
||||
"body": "echo no",
|
||||
})
|
||||
badBodyReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPatch,
|
||||
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items/"+libID,
|
||||
bytes.NewReader(badBodyPatch),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
badBodyRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(badBodyRec, badBodyReq)
|
||||
if badBodyRec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected clone-required for body edit, got %d", badBodyRec.Code)
|
||||
}
|
||||
|
||||
badCondBody, _ := json.Marshal(map[string]any{
|
||||
"run_if": "not valid",
|
||||
})
|
||||
badCondReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPatch,
|
||||
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items/"+localID,
|
||||
bytes.NewReader(badCondBody),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
badCondRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(badCondRec, badCondReq)
|
||||
if badCondRec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected bad run_if, got %d body=%s", badCondRec.Code, badCondRec.Body.String())
|
||||
}
|
||||
|
||||
sudoLocalBody, _ := json.Marshal(map[string]any{
|
||||
"requires_sudo": true,
|
||||
})
|
||||
sudoLocalReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPatch,
|
||||
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items/"+localID,
|
||||
bytes.NewReader(sudoLocalBody),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
sudoLocalRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(sudoLocalRec, sudoLocalReq)
|
||||
if sudoLocalRec.Code != http.StatusOK {
|
||||
t.Fatalf("patch requires_sudo status=%d body=%s", sudoLocalRec.Code, sudoLocalRec.Body.String())
|
||||
}
|
||||
var withSudo actionGroupResponse
|
||||
_ = json.Unmarshal(sudoLocalRec.Body.Bytes(), &withSudo)
|
||||
foundSudo := false
|
||||
for _, item := range withSudo.Group.Items {
|
||||
if item.ID == localID {
|
||||
foundSudo = true
|
||||
if !item.RequiresSudo {
|
||||
t.Fatalf("expected requires_sudo on local item, got %#v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundSudo {
|
||||
t.Fatal("local item missing after sudo patch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionGroupsForbiddenWithoutPermission(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
node := seedNodeForActions(t, configDir)
|
||||
|
||||
// Downgrade Administrators permissions to nodes.read only.
|
||||
payload, err := settings.LoadSettings(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSettings: %v", err)
|
||||
}
|
||||
payload.Groups[0].Permissions = []string{"nodes.read"}
|
||||
if err := settings.SaveSettings(configDir, payload); err != nil {
|
||||
t.Fatalf("SaveSettings: %v", err)
|
||||
}
|
||||
|
||||
router := NewRouter(configDir)
|
||||
body, _ := json.Marshal(map[string]any{"name": "Nope"})
|
||||
req := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/nodes/"+node.ID+"/action-groups",
|
||||
bytes.NewReader(body),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected forbidden, got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionGroupRunRequiresActionsExecute(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
node := seedNodeForActions(t, configDir)
|
||||
if _, err := settings.LoadActionsOrSeed(configDir); err != nil {
|
||||
t.Fatalf("LoadActionsOrSeed: %v", err)
|
||||
}
|
||||
|
||||
// Create a group while still admin, then strip execute (+ jobs.run so
|
||||
// migration does not re-grant actions.execute).
|
||||
router := NewRouter(configDir)
|
||||
createBody, _ := json.Marshal(map[string]any{
|
||||
"name": "RunAuth",
|
||||
"schedule": map[string]any{
|
||||
"enabled": false,
|
||||
"kind": "interval",
|
||||
"times": []any{},
|
||||
},
|
||||
})
|
||||
createReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/nodes/"+node.ID+"/action-groups",
|
||||
bytes.NewReader(createBody),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
createRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(createRec, createReq)
|
||||
if createRec.Code != http.StatusOK {
|
||||
t.Fatalf("create group status=%d body=%s", createRec.Code, createRec.Body.String())
|
||||
}
|
||||
var created actionGroupResponse
|
||||
_ = json.Unmarshal(createRec.Body.Bytes(), &created)
|
||||
|
||||
payload, err := settings.LoadSettings(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSettings: %v", err)
|
||||
}
|
||||
payload.Groups[0].Permissions = []string{"nodes.read", "actions.create", "actions.update"}
|
||||
if err := settings.SaveSettings(configDir, payload); err != nil {
|
||||
t.Fatalf("SaveSettings: %v", err)
|
||||
}
|
||||
|
||||
runReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/nodes/"+node.ID+"/action-groups/"+created.Group.ID+"/run",
|
||||
nil,
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
runRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(runRec, runReq)
|
||||
if runRec.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected run forbidden without actions.execute, got %d body=%s", runRec.Code, runRec.Body.String())
|
||||
}
|
||||
if !strings.Contains(runRec.Body.String(), "actions.execute") {
|
||||
t.Fatalf("expected actions.execute in error, got %s", runRec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionGroupCloneCopiesRequiresSudo(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
node := seedNodeForActions(t, configDir)
|
||||
if _, err := settings.LoadActionsOrSeed(configDir); err != nil {
|
||||
t.Fatalf("LoadActionsOrSeed: %v", err)
|
||||
}
|
||||
router := NewRouter(configDir)
|
||||
|
||||
createActionBody, _ := json.Marshal(map[string]any{
|
||||
"name": "Restart svc",
|
||||
"description": "",
|
||||
"kind": "shell",
|
||||
"body": "/usr/bin/systemctl restart myservice",
|
||||
"env": []any{},
|
||||
"requires_sudo": true,
|
||||
})
|
||||
createActionReq := withSession(
|
||||
httptest.NewRequest(http.MethodPost, "/api/v1/actions", bytes.NewReader(createActionBody)),
|
||||
cookie,
|
||||
)
|
||||
createActionRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(createActionRec, createActionReq)
|
||||
if createActionRec.Code != http.StatusOK {
|
||||
t.Fatalf("create action status=%d body=%s", createActionRec.Code, createActionRec.Body.String())
|
||||
}
|
||||
var actionsPayload actionsResponseTest
|
||||
if err := json.Unmarshal(createActionRec.Body.Bytes(), &actionsPayload); err != nil {
|
||||
t.Fatalf("decode actions: %v", err)
|
||||
}
|
||||
var libraryID string
|
||||
for _, action := range actionsPayload.Actions {
|
||||
if action.Name == "Restart svc" {
|
||||
libraryID = action.ID
|
||||
if !action.RequiresSudo {
|
||||
t.Fatalf("library action missing requires_sudo: %#v", action)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if libraryID == "" {
|
||||
t.Fatal("library action not found")
|
||||
}
|
||||
|
||||
createGroupBody, _ := json.Marshal(map[string]any{
|
||||
"name": "SudoGrp",
|
||||
"schedule": map[string]any{
|
||||
"enabled": false,
|
||||
"kind": "interval",
|
||||
"times": []any{},
|
||||
},
|
||||
})
|
||||
createGroupReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/nodes/"+node.ID+"/action-groups",
|
||||
bytes.NewReader(createGroupBody),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
createGroupRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(createGroupRec, createGroupReq)
|
||||
if createGroupRec.Code != http.StatusOK {
|
||||
t.Fatalf("create group status=%d", createGroupRec.Code)
|
||||
}
|
||||
var createdGroup actionGroupResponse
|
||||
_ = json.Unmarshal(createGroupRec.Body.Bytes(), &createdGroup)
|
||||
|
||||
addItemBody, _ := json.Marshal(map[string]any{
|
||||
"source": "library",
|
||||
"library_action_id": libraryID,
|
||||
})
|
||||
addItemReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/nodes/"+node.ID+"/action-groups/"+createdGroup.Group.ID+"/items",
|
||||
bytes.NewReader(addItemBody),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
addItemRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(addItemRec, addItemReq)
|
||||
if addItemRec.Code != http.StatusOK {
|
||||
t.Fatalf("add item status=%d body=%s", addItemRec.Code, addItemRec.Body.String())
|
||||
}
|
||||
var withItem actionGroupResponse
|
||||
_ = json.Unmarshal(addItemRec.Body.Bytes(), &withItem)
|
||||
itemID := withItem.Group.Items[0].ID
|
||||
|
||||
cloneReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/nodes/"+node.ID+"/action-groups/"+createdGroup.Group.ID+"/items/"+itemID+"/clone",
|
||||
nil,
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
cloneRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(cloneRec, cloneReq)
|
||||
if cloneRec.Code != http.StatusOK {
|
||||
t.Fatalf("clone status=%d body=%s", cloneRec.Code, cloneRec.Body.String())
|
||||
}
|
||||
var cloned actionGroupResponse
|
||||
_ = json.Unmarshal(cloneRec.Body.Bytes(), &cloned)
|
||||
clonedItem := cloned.Group.Items[0]
|
||||
if clonedItem.Source != settings.ActionItemSourceLocal || !clonedItem.RequiresSudo {
|
||||
t.Fatalf("cloned item should be local with requires_sudo: %#v", clonedItem)
|
||||
}
|
||||
if clonedItem.Body != "/usr/bin/systemctl restart myservice" {
|
||||
t.Fatalf("cloned body = %q", clonedItem.Body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||
)
|
||||
|
||||
type actionWidgetsResponse struct {
|
||||
Widgets []settings.ActionWidgetSnapshot `json:"widgets"`
|
||||
}
|
||||
|
||||
func (app *App) actionWidgetsListHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
node, ok := app.loadAccessibleNode(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
store, err := settings.LoadActionWidgetsOrEmpty(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, actionWidgetsResponse{
|
||||
Widgets: settings.WidgetsForNode(store, node.ID),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
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"`
|
||||
ShowWidget bool `json:"show_widget"`
|
||||
WidgetType settings.ActionWidgetType `json:"widget_type"`
|
||||
}
|
||||
|
||||
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"`
|
||||
ShowWidget *bool `json:"show_widget"`
|
||||
WidgetType *settings.ActionWidgetType `json:"widget_type"`
|
||||
}
|
||||
|
||||
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")
|
||||
errActionBuiltinBodyRO = errors.New("built-in action body and name cannot be modified")
|
||||
errActionIDRequired = errors.New("action id is required")
|
||||
errActionWidgetTypeNeeded = errors.New("widget_type is required when show_widget is true")
|
||||
errActionWidgetTypeBad = errors.New("widget_type must be memory, disk, or raw")
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
updated := store.Actions[index]
|
||||
isBuiltin := updated.Builtin
|
||||
if isBuiltin {
|
||||
if payload.Name != nil || payload.Description != nil || payload.Body != nil ||
|
||||
payload.Env != nil || payload.RequiresSudo != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: errActionBuiltinBodyRO.Error()})
|
||||
return
|
||||
}
|
||||
if payload.ShowWidget == nil && payload.WidgetType == nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: errActionBuiltinRO.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if !isBuiltin {
|
||||
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
|
||||
}
|
||||
}
|
||||
if payload.ShowWidget != nil {
|
||||
updated.ShowWidget = *payload.ShowWidget
|
||||
}
|
||||
if payload.WidgetType != nil {
|
||||
updated.WidgetType = *payload.WidgetType
|
||||
}
|
||||
if !updated.ShowWidget {
|
||||
updated.WidgetType = settings.ActionWidgetTypeNone
|
||||
}
|
||||
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 err := validateActionWidget(updated.ShowWidget, updated.WidgetType); 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
|
||||
}
|
||||
widgetType := payload.WidgetType
|
||||
if !payload.ShowWidget {
|
||||
widgetType = settings.ActionWidgetTypeNone
|
||||
}
|
||||
if err := validateActionWidget(payload.ShowWidget, widgetType); 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,
|
||||
ShowWidget: payload.ShowWidget,
|
||||
WidgetType: widgetType,
|
||||
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 validateActionWidget(showWidget bool, widgetType settings.ActionWidgetType) error {
|
||||
if !showWidget {
|
||||
return nil
|
||||
}
|
||||
switch widgetType {
|
||||
case settings.ActionWidgetTypeMemory, settings.ActionWidgetTypeDisk, settings.ActionWidgetTypeRaw:
|
||||
return nil
|
||||
case settings.ActionWidgetTypeNone:
|
||||
return errActionWidgetTypeNeeded
|
||||
default:
|
||||
return errActionWidgetTypeBad
|
||||
}
|
||||
}
|
||||
|
||||
func findActionIndex(actions []settings.Action, actionID string) int {
|
||||
for index := range actions {
|
||||
if actions[index].ID == actionID {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
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) != 4 {
|
||||
t.Fatalf("expected 4 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) != 5 {
|
||||
t.Fatalf("expected 5 actions after create, got %d", len(created.Actions))
|
||||
}
|
||||
custom := created.Actions[4]
|
||||
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) != 4 {
|
||||
t.Fatalf("expected 4 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)
|
||||
}
|
||||
|
||||
widgetBody := []byte(`{"show_widget":true,"widget_type":"raw"}`)
|
||||
widgetReq := withSession(
|
||||
httptest.NewRequest(http.MethodPatch, "/api/v1/actions?id="+builtinID, bytes.NewReader(widgetBody)),
|
||||
cookie,
|
||||
)
|
||||
widgetRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(widgetRec, widgetReq)
|
||||
if widgetRec.Code != http.StatusOK {
|
||||
t.Fatalf("expected builtin widget patch 200, got %d body=%s", widgetRec.Code, widgetRec.Body.String())
|
||||
}
|
||||
var widgetPatched actionsResponseTest
|
||||
if err := json.NewDecoder(widgetRec.Body).Decode(&widgetPatched); err != nil {
|
||||
t.Fatalf("decode widget patch: %v", err)
|
||||
}
|
||||
foundWidget := false
|
||||
for _, action := range widgetPatched.Actions {
|
||||
if action.ID == builtinID {
|
||||
foundWidget = true
|
||||
if !action.ShowWidget || action.WidgetType != settings.ActionWidgetTypeRaw {
|
||||
t.Fatalf("widget patched = %#v", action)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundWidget {
|
||||
t.Fatal("builtin missing after widget patch")
|
||||
}
|
||||
|
||||
deleteReq := withSession(
|
||||
httptest.NewRequest(http.MethodDelete, "/api/v1/actions?id="+builtinID, nil),
|
||||
cookie,
|
||||
)
|
||||
deleteRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(deleteRec, deleteReq)
|
||||
if deleteRec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected builtin delete 400, got %d", deleteRec.Code)
|
||||
}
|
||||
|
||||
badCreate := []byte(`{"name":"x","kind":"shell","body":"echo","env":[{"name":"bad-name","value":"1"}]}`)
|
||||
badReq := withSession(httptest.NewRequest(http.MethodPost, "/api/v1/actions", bytes.NewReader(badCreate)), cookie)
|
||||
badRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(badRec, badReq)
|
||||
if badRec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected invalid env 400, got %d", badRec.Code)
|
||||
}
|
||||
|
||||
invalidKind := []byte(`{"name":"x","kind":"nope","body":"echo"}`)
|
||||
kindReq := withSession(httptest.NewRequest(http.MethodPost, "/api/v1/actions", bytes.NewReader(invalidKind)), cookie)
|
||||
kindRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(kindRec, kindReq)
|
||||
if kindRec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected invalid kind 400, got %d", kindRec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionsMutationsRequirePermissions(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
router := NewRouter(configDir)
|
||||
|
||||
// Strip action permissions from Administrators.
|
||||
settingsPayload, err := settings.LoadSettings(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSettings: %v", err)
|
||||
}
|
||||
for index := range settingsPayload.Groups {
|
||||
if settingsPayload.Groups[index].Name != settings.AdministratorsGroupName {
|
||||
continue
|
||||
}
|
||||
filtered := make([]string, 0, len(settingsPayload.Groups[index].Permissions))
|
||||
for _, permission := range settingsPayload.Groups[index].Permissions {
|
||||
if permission == "actions.create" || permission == "actions.update" || permission == "actions.delete" {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, permission)
|
||||
}
|
||||
settingsPayload.Groups[index].Permissions = filtered
|
||||
}
|
||||
if err := settings.SaveSettings(configDir, settingsPayload); err != nil {
|
||||
t.Fatalf("SaveSettings: %v", err)
|
||||
}
|
||||
|
||||
createBody := []byte(`{"name":"x","kind":"shell","body":"echo"}`)
|
||||
createReq := withSession(httptest.NewRequest(http.MethodPost, "/api/v1/actions", bytes.NewReader(createBody)), cookie)
|
||||
createRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(createRec, createReq)
|
||||
if createRec.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected create 403, got %d body=%s", createRec.Code, createRec.Body.String())
|
||||
}
|
||||
|
||||
patchReq := withSession(
|
||||
httptest.NewRequest(http.MethodPatch, "/api/v1/actions?id=custom", bytes.NewReader([]byte(`{"name":"y"}`))),
|
||||
cookie,
|
||||
)
|
||||
patchRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(patchRec, patchReq)
|
||||
if patchRec.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected patch 403, got %d", patchRec.Code)
|
||||
}
|
||||
|
||||
deleteReq := withSession(httptest.NewRequest(http.MethodDelete, "/api/v1/actions?id=custom", nil), cookie)
|
||||
deleteRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(deleteRec, deleteReq)
|
||||
if deleteRec.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected delete 403, got %d", deleteRec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||
)
|
||||
|
||||
type authLogsResponse struct {
|
||||
Events []settings.AuthAuditEvent `json:"events"`
|
||||
}
|
||||
|
||||
func (app *App) authLogsListHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := app.authorizeLogsRead(request); err != nil {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
store, err := settings.LoadAuthAuditOrEmpty(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
filtered := make([]settings.AuthAuditEvent, 0, len(store.Events))
|
||||
for index := len(store.Events) - 1; index >= 0; index-- {
|
||||
filtered = append(filtered, store.Events[index])
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, authLogsResponse{Events: filtered})
|
||||
}
|
||||
|
||||
func appendAuthAudit(
|
||||
configDir string,
|
||||
action string,
|
||||
outcome settings.AuthAuditOutcome,
|
||||
category settings.AuthAuditCategory,
|
||||
actor string,
|
||||
target string,
|
||||
detail string,
|
||||
) {
|
||||
eventID, err := auth.NewUUID()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = settings.AppendAuthAuditEvent(configDir, settings.AuthAuditEvent{
|
||||
ID: eventID,
|
||||
At: time.Now().UTC(),
|
||||
Action: action,
|
||||
Outcome: outcome,
|
||||
Category: category,
|
||||
Actor: actor,
|
||||
Target: target,
|
||||
Detail: detail,
|
||||
})
|
||||
}
|
||||
|
||||
func actorUsernameFromRequest(request *http.Request) string {
|
||||
user, ok := UserFromContext(request.Context())
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return user.Username
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||
)
|
||||
|
||||
func TestAuthLogsRequiresLogsRead(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
|
||||
payload, err := settings.LoadSettings(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSettings: %v", err)
|
||||
}
|
||||
for index := range payload.Groups {
|
||||
if payload.Groups[index].Name != settings.AdministratorsGroupName {
|
||||
continue
|
||||
}
|
||||
// Strip logs.read and the permissions that would trigger migration.
|
||||
filtered := make([]string, 0)
|
||||
for _, permission := range payload.Groups[index].Permissions {
|
||||
switch permission {
|
||||
case "logs.read", "nodes.read", "users.manage":
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, permission)
|
||||
}
|
||||
payload.Groups[index].Permissions = filtered
|
||||
}
|
||||
if err := settings.SaveSettings(configDir, payload); err != nil {
|
||||
t.Fatalf("SaveSettings: %v", err)
|
||||
}
|
||||
|
||||
router := NewRouter(configDir)
|
||||
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/auth-logs", nil), cookie)
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected %d, got %d body=%s", http.StatusForbidden, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogsAllowsLogsReadWithoutUsersManage(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
|
||||
payload, err := settings.LoadSettings(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSettings: %v", err)
|
||||
}
|
||||
for index := range payload.Groups {
|
||||
if payload.Groups[index].Name != settings.AdministratorsGroupName {
|
||||
continue
|
||||
}
|
||||
payload.Groups[index].Permissions = []string{"logs.read"}
|
||||
}
|
||||
if err := settings.SaveSettings(configDir, payload); err != nil {
|
||||
t.Fatalf("SaveSettings: %v", err)
|
||||
}
|
||||
|
||||
router := NewRouter(configDir)
|
||||
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/auth-logs", nil), cookie)
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogsRecordsLoginAndUserCreate(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
_ = seedCompletedSetup(t, configDir)
|
||||
router := NewRouter(configDir)
|
||||
|
||||
failBody := []byte(`{"username":"Admin","password":"wrong password that fails"}`)
|
||||
failReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(failBody))
|
||||
failRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(failRec, failReq)
|
||||
if failRec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("failed login status %d body %s", failRec.Code, failRec.Body.String())
|
||||
}
|
||||
|
||||
okBody := []byte(`{"username":"Admin","password":"correct horse battery staple extra"}`)
|
||||
okReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(okBody))
|
||||
okRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(okRec, okReq)
|
||||
if okRec.Code != http.StatusOK {
|
||||
t.Fatalf("login status %d body %s", okRec.Code, okRec.Body.String())
|
||||
}
|
||||
var loginCookie *http.Cookie
|
||||
for _, candidate := range okRec.Result().Cookies() {
|
||||
if candidate.Name == auth.SessionCookieName {
|
||||
loginCookie = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if loginCookie == nil {
|
||||
t.Fatal("login did not set session cookie")
|
||||
}
|
||||
|
||||
createReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/users",
|
||||
strings.NewReader(`{"username":"alice","password":"correct horse battery staple extra","group_names":[]}`),
|
||||
),
|
||||
loginCookie,
|
||||
)
|
||||
createReq.Header.Set("Content-Type", "application/json")
|
||||
createRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(createRec, createReq)
|
||||
if createRec.Code != http.StatusOK {
|
||||
t.Fatalf("create user status %d body %s", createRec.Code, createRec.Body.String())
|
||||
}
|
||||
|
||||
logsReq := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/auth-logs", nil), loginCookie)
|
||||
logsRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(logsRec, logsReq)
|
||||
if logsRec.Code != http.StatusOK {
|
||||
t.Fatalf("auth-logs status %d body %s", logsRec.Code, logsRec.Body.String())
|
||||
}
|
||||
|
||||
var logs authLogsResponse
|
||||
if err := json.Unmarshal(logsRec.Body.Bytes(), &logs); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(logs.Events) < 3 {
|
||||
t.Fatalf("expected at least 3 events, got %d", len(logs.Events))
|
||||
}
|
||||
|
||||
var sawLoginFailure, sawLoginSuccess, sawUserCreate bool
|
||||
for _, event := range logs.Events {
|
||||
switch {
|
||||
case event.Action == "login" && event.Outcome == settings.AuthAuditOutcomeFailure:
|
||||
sawLoginFailure = true
|
||||
if event.Detail != "invalid credentials" {
|
||||
t.Fatalf("failure detail = %q", event.Detail)
|
||||
}
|
||||
case event.Action == "login" && event.Outcome == settings.AuthAuditOutcomeSuccess:
|
||||
sawLoginSuccess = true
|
||||
if event.Actor != "Admin" {
|
||||
t.Fatalf("login actor = %q", event.Actor)
|
||||
}
|
||||
case event.Action == "user_create" && event.Target == "alice":
|
||||
sawUserCreate = true
|
||||
if event.Category != settings.AuthAuditCategoryUser {
|
||||
t.Fatalf("category = %q", event.Category)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !sawLoginFailure || !sawLoginSuccess || !sawUserCreate {
|
||||
t.Fatalf(
|
||||
"missing events: failure=%v success=%v create=%v events=%+v",
|
||||
sawLoginFailure,
|
||||
sawLoginSuccess,
|
||||
sawUserCreate,
|
||||
logs.Events,
|
||||
)
|
||||
}
|
||||
|
||||
// Newest first: user_create should appear before older login events.
|
||||
if logs.Events[0].Action != "user_create" {
|
||||
t.Fatalf("expected newest event user_create, got %q", logs.Events[0].Action)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateLogsReadPermission(t *testing.T) {
|
||||
payload := settings.Settings{
|
||||
Groups: []settings.Group{
|
||||
{Name: "ops", Permissions: []string{"nodes.read", "jobs.read"}},
|
||||
{Name: "hr", Permissions: []string{"users.manage"}},
|
||||
{Name: "auditors", Permissions: []string{"logs.read"}},
|
||||
{Name: "viewers", Permissions: []string{"jobs.read"}},
|
||||
},
|
||||
}
|
||||
if !migrateLogsReadPermission(&payload) {
|
||||
t.Fatal("expected migration to change settings")
|
||||
}
|
||||
if !containsPermission(payload.Groups[0].Permissions, "logs.read") {
|
||||
t.Fatal("ops should gain logs.read from nodes.read")
|
||||
}
|
||||
if !containsPermission(payload.Groups[1].Permissions, "logs.read") {
|
||||
t.Fatal("hr should gain logs.read from users.manage")
|
||||
}
|
||||
if len(payload.Groups[2].Permissions) != 1 {
|
||||
t.Fatal("auditors should be unchanged")
|
||||
}
|
||||
if containsPermission(payload.Groups[3].Permissions, "logs.read") {
|
||||
t.Fatal("viewers should not gain logs.read")
|
||||
}
|
||||
if migrateLogsReadPermission(&payload) {
|
||||
t.Fatal("second migration should be a no-op")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateActionsExecutePermission(t *testing.T) {
|
||||
payload := settings.Settings{
|
||||
Groups: []settings.Group{
|
||||
{Name: "runners", Permissions: []string{"jobs.run", "nodes.read"}},
|
||||
{Name: "already", Permissions: []string{"jobs.run", "actions.execute"}},
|
||||
{Name: "viewers", Permissions: []string{"jobs.read"}},
|
||||
},
|
||||
}
|
||||
if !migrateActionsExecutePermission(&payload) {
|
||||
t.Fatal("expected migration to change settings")
|
||||
}
|
||||
if !containsPermission(payload.Groups[0].Permissions, "actions.execute") {
|
||||
t.Fatal("runners should gain actions.execute from jobs.run")
|
||||
}
|
||||
if len(payload.Groups[1].Permissions) != 2 {
|
||||
t.Fatal("already should be unchanged")
|
||||
}
|
||||
if containsPermission(payload.Groups[2].Permissions, "actions.execute") {
|
||||
t.Fatal("viewers should not gain actions.execute")
|
||||
}
|
||||
if migrateActionsExecutePermission(&payload) {
|
||||
t.Fatal("second migration should be a no-op")
|
||||
}
|
||||
}
|
||||
|
||||
func containsPermission(permissions []string, wanted string) bool {
|
||||
for _, permission := range permissions {
|
||||
if permission == wanted {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,794 @@
|
||||
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),
|
||||
)
|
||||
|
||||
if node.HealthCheckIntervalSeconds > 0 && app.HealthChecker != nil {
|
||||
nodeID := node.ID
|
||||
actor := user.Username
|
||||
go func() {
|
||||
_, _ = app.HealthChecker.CheckNodeByID(nodeID, actor)
|
||||
}()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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,361 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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 TestNodesPatchHealthIntervalTriggersBackgroundCheck(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
|
||||
}
|
||||
|
||||
const nodeID = "ffffffff-bbbb-4ccc-8ddd-eeeeeeeeeeee"
|
||||
createBody := []byte(`{
|
||||
"id":"` + nodeID + `",
|
||||
"kind":"container",
|
||||
"name":"bg-check-node",
|
||||
"host_ip":"10.20.30.44",
|
||||
"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":60}`)
|
||||
patchRequest := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPatch,
|
||||
"/api/v1/nodes/"+nodeID,
|
||||
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())
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
getRequest := withSession(
|
||||
httptest.NewRequest(http.MethodGet, "/api/v1/nodes/"+nodeID, nil),
|
||||
cookie,
|
||||
)
|
||||
getRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(getRecorder, getRequest)
|
||||
if getRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("get status = %d body=%s", getRecorder.Code, getRecorder.Body.String())
|
||||
}
|
||||
|
||||
var response nodeResponse
|
||||
if err := json.Unmarshal(getRecorder.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if response.Node.HealthOK != nil && *response.Node.HealthOK {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("timed out waiting for background health check, node=%#v", response.Node)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodesPatchHealthIntervalZeroSkipsBackgroundCheck(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
router, app := NewRouterWithApp(configDir)
|
||||
|
||||
checked := atomic.Bool{}
|
||||
app.HealthChecker.PingFn = func(hostIP string) auth.PingResult {
|
||||
checked.Store(true)
|
||||
return auth.PingResult{OK: true, Method: auth.PingMethodICMP}
|
||||
}
|
||||
app.HealthChecker.SSHFn = func(hostIP string, username string, privateKeyPEM string, passphrase string) error {
|
||||
checked.Store(true)
|
||||
return nil
|
||||
}
|
||||
|
||||
const nodeID = "aaaaaaaa-cccc-4ddd-8eee-ffffffffffff"
|
||||
createBody := []byte(`{
|
||||
"id":"` + nodeID + `",
|
||||
"kind":"vm",
|
||||
"name":"no-bg-check-node",
|
||||
"host_ip":"10.20.30.45",
|
||||
"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":0}`)
|
||||
patchRequest := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPatch,
|
||||
"/api/v1/nodes/"+nodeID,
|
||||
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())
|
||||
}
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
getRequest := withSession(
|
||||
httptest.NewRequest(http.MethodGet, "/api/v1/nodes/"+nodeID, nil),
|
||||
cookie,
|
||||
)
|
||||
getRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(getRecorder, getRequest)
|
||||
if getRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("get status = %d body=%s", getRecorder.Code, getRecorder.Body.String())
|
||||
}
|
||||
|
||||
var response nodeResponse
|
||||
if err := json.Unmarshal(getRecorder.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if checked.Load() {
|
||||
t.Fatal("expected no background health check when interval is 0")
|
||||
}
|
||||
if response.Node.HealthOK != nil {
|
||||
t.Fatalf("expected health_ok unset, got %#v", response.Node.HealthOK)
|
||||
}
|
||||
if response.Node.HealthLastCheckedAt != nil {
|
||||
t.Fatalf("expected health_last_checked_at unset, got %#v", response.Node.HealthLastCheckedAt)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
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/nodes/{id}/action-widgets", app.actionWidgetsListHandler)
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
PingMethodICMP = "icmp"
|
||||
PingMethodTCP = "tcp"
|
||||
|
||||
defaultPingCount = "1"
|
||||
defaultPingWaitSec = "2"
|
||||
defaultTCPTimeout = 3 * time.Second
|
||||
)
|
||||
|
||||
// PingResult is the outcome of a reachability probe.
|
||||
type PingResult struct {
|
||||
OK bool
|
||||
Method string
|
||||
Error error
|
||||
}
|
||||
|
||||
type pingCommandFunc func(hostIP string) error
|
||||
type tcpDialFunc func(address string, timeout time.Duration) error
|
||||
|
||||
var (
|
||||
runICMPPing = defaultRunICMPPing
|
||||
dialTCP = defaultDialTCP
|
||||
)
|
||||
|
||||
// PingHost probes host reachability: ICMP first, then TCP :22 when ICMP is
|
||||
// unavailable or the host does not respond to ICMP.
|
||||
func PingHost(hostIP string) PingResult {
|
||||
hostIP = strings.TrimSpace(hostIP)
|
||||
if hostIP == "" {
|
||||
return PingResult{OK: false, Error: fmt.Errorf("host IP is required")}
|
||||
}
|
||||
|
||||
icmpErr := runICMPPing(hostIP)
|
||||
if icmpErr == nil {
|
||||
return PingResult{OK: true, Method: PingMethodICMP}
|
||||
}
|
||||
|
||||
address := net.JoinHostPort(hostIP, defaultSSHPort)
|
||||
tcpErr := dialTCP(address, defaultTCPTimeout)
|
||||
if tcpErr == nil {
|
||||
return PingResult{OK: true, Method: PingMethodTCP}
|
||||
}
|
||||
|
||||
return PingResult{
|
||||
OK: false,
|
||||
Method: PingMethodTCP,
|
||||
Error: fmt.Errorf(
|
||||
"icmp: %v; tcp %s: %w",
|
||||
icmpErr,
|
||||
address,
|
||||
tcpErr,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func defaultRunICMPPing(hostIP string) error {
|
||||
pingPath, err := exec.LookPath("ping")
|
||||
if err != nil {
|
||||
return fmt.Errorf("ping unavailable: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command(pingPath, "-c", defaultPingCount, "-W", defaultPingWaitSec, hostIP)
|
||||
output, runErr := cmd.CombinedOutput()
|
||||
if runErr == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
combined := strings.TrimSpace(string(output) + " " + runErr.Error())
|
||||
return fmt.Errorf("%s", strings.TrimSpace(combined))
|
||||
}
|
||||
|
||||
func defaultDialTCP(address string, timeout time.Duration) error {
|
||||
conn, err := net.DialTimeout("tcp", address, timeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = conn.Close()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPingHostUsesICMPWhenAvailable(t *testing.T) {
|
||||
originalPing := runICMPPing
|
||||
originalDial := dialTCP
|
||||
t.Cleanup(func() {
|
||||
runICMPPing = originalPing
|
||||
dialTCP = originalDial
|
||||
})
|
||||
|
||||
runICMPPing = func(hostIP string) error {
|
||||
if hostIP != "10.0.0.1" {
|
||||
t.Fatalf("host = %q", hostIP)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
dialTCP = func(address string, timeout time.Duration) error {
|
||||
t.Fatal("tcp should not be used when icmp succeeds")
|
||||
return nil
|
||||
}
|
||||
|
||||
result := PingHost("10.0.0.1")
|
||||
if !result.OK {
|
||||
t.Fatalf("expected ok, got %#v", result)
|
||||
}
|
||||
if result.Method != PingMethodICMP {
|
||||
t.Fatalf("method = %q", result.Method)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPingHostFallsBackToTCPWhenICMPFails(t *testing.T) {
|
||||
originalPing := runICMPPing
|
||||
originalDial := dialTCP
|
||||
t.Cleanup(func() {
|
||||
runICMPPing = originalPing
|
||||
dialTCP = originalDial
|
||||
})
|
||||
|
||||
runICMPPing = func(hostIP string) error {
|
||||
return errors.New("ping unavailable: executable file not found")
|
||||
}
|
||||
dialTCP = func(address string, timeout time.Duration) error {
|
||||
if address != "10.0.0.2:22" {
|
||||
t.Fatalf("address = %q", address)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
result := PingHost("10.0.0.2")
|
||||
if !result.OK {
|
||||
t.Fatalf("expected ok, got %#v", result)
|
||||
}
|
||||
if result.Method != PingMethodTCP {
|
||||
t.Fatalf("method = %q", result.Method)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPingHostFailsWhenBothFail(t *testing.T) {
|
||||
originalPing := runICMPPing
|
||||
originalDial := dialTCP
|
||||
t.Cleanup(func() {
|
||||
runICMPPing = originalPing
|
||||
dialTCP = originalDial
|
||||
})
|
||||
|
||||
runICMPPing = func(hostIP string) error {
|
||||
return errors.New("100% packet loss")
|
||||
}
|
||||
dialTCP = func(address string, timeout time.Duration) error {
|
||||
return errors.New("connection refused")
|
||||
}
|
||||
|
||||
result := PingHost("10.0.0.3")
|
||||
if result.OK {
|
||||
t.Fatal("expected failure")
|
||||
}
|
||||
if result.Error == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if result.Method != PingMethodTCP {
|
||||
t.Fatalf("method = %q", result.Method)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPingHostRequiresHostIP(t *testing.T) {
|
||||
result := PingHost(" ")
|
||||
if result.OK || result.Error == nil {
|
||||
t.Fatalf("expected failure, got %#v", result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var placeholderPattern = regexp.MustCompile(`\{\{\s*([a-zA-Z0-9_.]+)\s*\}\}`)
|
||||
|
||||
// PlaceholderContext holds values used to expand action body placeholders.
|
||||
type PlaceholderContext struct {
|
||||
Host string
|
||||
IP string
|
||||
Username string
|
||||
Env map[string]string
|
||||
Secrets map[string]string
|
||||
}
|
||||
|
||||
// ExpandPlaceholders replaces {{cc.host}}, {{node.ip}}, {{env.NAME}}, etc.
|
||||
// Unknown or unresolved secrets expand to empty strings.
|
||||
func ExpandPlaceholders(body string, ctx PlaceholderContext) string {
|
||||
return placeholderPattern.ReplaceAllStringFunc(body, func(match string) string {
|
||||
submatches := placeholderPattern.FindStringSubmatch(match)
|
||||
if len(submatches) < 2 {
|
||||
return match
|
||||
}
|
||||
key := strings.TrimSpace(submatches[1])
|
||||
switch key {
|
||||
case "cc.host", "node.host":
|
||||
return ctx.Host
|
||||
case "cc.ip", "node.ip":
|
||||
return ctx.IP
|
||||
case "node.username":
|
||||
return ctx.Username
|
||||
default:
|
||||
if strings.HasPrefix(key, "env.") {
|
||||
name := strings.TrimPrefix(key, "env.")
|
||||
if ctx.Env != nil {
|
||||
return ctx.Env[name]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(key, "secret.") {
|
||||
name := strings.TrimPrefix(key, "secret.")
|
||||
if ctx.Secrets != nil {
|
||||
return ctx.Secrets[name]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return ""
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package auth
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExpandPlaceholders(t *testing.T) {
|
||||
body := "ping {{node.ip}} as {{node.username}} host={{cc.host}} env={{env.FOO}} secret={{secret.TOKEN}} unknown={{nope}}"
|
||||
got := ExpandPlaceholders(body, PlaceholderContext{
|
||||
Host: "web-1",
|
||||
IP: "10.0.0.5",
|
||||
Username: "deploy",
|
||||
Env: map[string]string{"FOO": "bar"},
|
||||
Secrets: map[string]string{},
|
||||
})
|
||||
want := "ping 10.0.0.5 as deploy host=web-1 env=bar secret= unknown="
|
||||
if got != want {
|
||||
t.Fatalf("got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const MaxResolveCommandNames = 32
|
||||
|
||||
// ValidateCommandNames checks relative command basenames for resolve-commands.
|
||||
func ValidateCommandNames(names []string) ([]string, error) {
|
||||
if len(names) == 0 {
|
||||
return nil, fmt.Errorf("names is required")
|
||||
}
|
||||
if len(names) > MaxResolveCommandNames {
|
||||
return nil, fmt.Errorf("at most %d names allowed", MaxResolveCommandNames)
|
||||
}
|
||||
|
||||
cleaned := make([]string, 0, len(names))
|
||||
seen := make(map[string]struct{}, len(names))
|
||||
for _, raw := range names {
|
||||
name := strings.TrimSpace(raw)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("command name must not be empty")
|
||||
}
|
||||
if strings.Contains(name, "/") {
|
||||
return nil, fmt.Errorf("command name must be a basename without /")
|
||||
}
|
||||
if len(name) > 64 {
|
||||
return nil, fmt.Errorf("command name too long")
|
||||
}
|
||||
for _, runeValue := range name {
|
||||
if (runeValue >= 'a' && runeValue <= 'z') ||
|
||||
(runeValue >= 'A' && runeValue <= 'Z') ||
|
||||
(runeValue >= '0' && runeValue <= '9') ||
|
||||
runeValue == '.' || runeValue == '_' || runeValue == '-' || runeValue == '+' {
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("command name contains invalid characters")
|
||||
}
|
||||
if _, exists := seen[name]; exists {
|
||||
continue
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
cleaned = append(cleaned, name)
|
||||
}
|
||||
if len(cleaned) == 0 {
|
||||
return nil, fmt.Errorf("names is required")
|
||||
}
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
// BuildResolveCommandsRemote builds a shell snippet that prints name=path lines.
|
||||
func BuildResolveCommandsRemote(names []string) string {
|
||||
var builder strings.Builder
|
||||
for _, name := range names {
|
||||
quoted := shellQuote(name)
|
||||
builder.WriteString("path=$(command -v -- ")
|
||||
builder.WriteString(quoted)
|
||||
builder.WriteString(" 2>/dev/null || true); printf '%s=%s\\n' ")
|
||||
builder.WriteString(quoted)
|
||||
builder.WriteString(" \"$path\"; ")
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
// ParseResolveCommandsOutput parses name=path lines into found paths and missing names.
|
||||
func ParseResolveCommandsOutput(names []string, stdout string) (paths map[string]string, missing []string) {
|
||||
paths = make(map[string]string, len(names))
|
||||
wanted := make(map[string]struct{}, len(names))
|
||||
for _, name := range names {
|
||||
wanted[name] = struct{}{}
|
||||
}
|
||||
|
||||
for _, line := range strings.Split(stdout, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
name, path, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
path = strings.TrimSpace(path)
|
||||
if _, ok := wanted[name]; !ok {
|
||||
continue
|
||||
}
|
||||
if path == "" || !strings.HasPrefix(path, "/") {
|
||||
continue
|
||||
}
|
||||
paths[name] = path
|
||||
}
|
||||
|
||||
for _, name := range names {
|
||||
if _, ok := paths[name]; !ok {
|
||||
missing = append(missing, name)
|
||||
}
|
||||
}
|
||||
return paths, missing
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateCommandNames(t *testing.T) {
|
||||
cleaned, err := ValidateCommandNames([]string{" apt ", "systemctl", "apt"})
|
||||
if err != nil {
|
||||
t.Fatalf("valid names: %v", err)
|
||||
}
|
||||
if len(cleaned) != 2 || cleaned[0] != "apt" || cleaned[1] != "systemctl" {
|
||||
t.Fatalf("cleaned = %#v", cleaned)
|
||||
}
|
||||
|
||||
if _, err := ValidateCommandNames(nil); err == nil {
|
||||
t.Fatal("expected error for empty names")
|
||||
}
|
||||
if _, err := ValidateCommandNames([]string{"/usr/bin/apt"}); err == nil {
|
||||
t.Fatal("expected error for path")
|
||||
}
|
||||
if _, err := ValidateCommandNames([]string{"apt;rm"}); err == nil {
|
||||
t.Fatal("expected error for metacharacters")
|
||||
}
|
||||
|
||||
tooMany := make([]string, MaxResolveCommandNames+1)
|
||||
for index := range tooMany {
|
||||
tooMany[index] = fmt.Sprintf("cmd%d", index)
|
||||
}
|
||||
if _, err := ValidateCommandNames(tooMany); err == nil {
|
||||
t.Fatal("expected error for too many names")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseResolveCommandsOutput(t *testing.T) {
|
||||
paths, missing := ParseResolveCommandsOutput(
|
||||
[]string{"apt", "missing", "systemctl"},
|
||||
"apt=/usr/bin/apt\nmissing=\nsystemctl=/usr/bin/systemctl\n",
|
||||
)
|
||||
if paths["apt"] != "/usr/bin/apt" || paths["systemctl"] != "/usr/bin/systemctl" {
|
||||
t.Fatalf("paths = %#v", paths)
|
||||
}
|
||||
if len(missing) != 1 || missing[0] != "missing" {
|
||||
t.Fatalf("missing = %#v", missing)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResolveCommandsRemote(t *testing.T) {
|
||||
remote := BuildResolveCommandsRemote([]string{"apt"})
|
||||
if !strings.Contains(remote, "command -v -- 'apt'") {
|
||||
t.Fatalf("remote = %q", remote)
|
||||
}
|
||||
if !strings.Contains(remote, "printf '%s=%s\\n' 'apt'") {
|
||||
t.Fatalf("remote missing printf = %q", remote)
|
||||
}
|
||||
}
|
||||
@@ -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 = ©Session
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||
)
|
||||
|
||||
func TestLookupValidSessionReturnsIdle(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
key := testSessionKey()
|
||||
manager := NewSessionManager(configDir, key)
|
||||
security := settings.DefaultSecuritySettings()
|
||||
security.IdleTimeoutMinutes = 5
|
||||
|
||||
createRec := httptest.NewRecorder()
|
||||
record, err := manager.CreateSession(createRec, "user-1", security)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
store, err := settings.LoadSessions(configDir, key)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSessions: %v", err)
|
||||
}
|
||||
if len(store.Sessions) != 1 {
|
||||
t.Fatalf("expected 1 session, got %d", len(store.Sessions))
|
||||
}
|
||||
store.Sessions[0].LastSeenAt = time.Now().UTC().Add(-6 * time.Minute)
|
||||
if err := settings.SaveSessions(configDir, store, key); err != nil {
|
||||
t.Fatalf("SaveSessions: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
|
||||
request.AddCookie(&http.Cookie{Name: SessionCookieName, Value: record.ID})
|
||||
lookupRec := httptest.NewRecorder()
|
||||
|
||||
_, err = manager.LookupValidSession(lookupRec, request, security, true)
|
||||
if !errors.Is(err, ErrSessionIdle) {
|
||||
t.Fatalf("expected ErrSessionIdle, got %v", err)
|
||||
}
|
||||
|
||||
store, err = settings.LoadSessionsOrEmpty(configDir, key)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSessionsOrEmpty: %v", err)
|
||||
}
|
||||
if len(store.Sessions) != 0 {
|
||||
t.Fatalf("expected idle session removed, got %#v", store.Sessions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupValidSessionMissingCookie(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
manager := NewSessionManager(configDir, testSessionKey())
|
||||
security := settings.DefaultSecuritySettings()
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
|
||||
lookupRec := httptest.NewRecorder()
|
||||
|
||||
_, err := manager.LookupValidSession(lookupRec, request, security, true)
|
||||
if !errors.Is(err, ErrSessionNotFound) {
|
||||
t.Fatalf("expected ErrSessionNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupValidSessionUnknownCookie(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
manager := NewSessionManager(configDir, testSessionKey())
|
||||
security := settings.DefaultSecuritySettings()
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
|
||||
request.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "unknown-session"})
|
||||
lookupRec := httptest.NewRecorder()
|
||||
|
||||
_, err := manager.LookupValidSession(lookupRec, request, security, true)
|
||||
if !errors.Is(err, ErrSessionNotFound) {
|
||||
t.Fatalf("expected ErrSessionNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupValidSessionWithoutTouchLeavesLastSeenAt(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
key := testSessionKey()
|
||||
manager := NewSessionManager(configDir, key)
|
||||
security := settings.DefaultSecuritySettings()
|
||||
security.IdleTimeoutMinutes = 30
|
||||
|
||||
createRec := httptest.NewRecorder()
|
||||
record, err := manager.CreateSession(createRec, "user-1", security)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
store, err := settings.LoadSessions(configDir, key)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSessions: %v", err)
|
||||
}
|
||||
originalLastSeen := time.Now().UTC().Add(-10 * time.Minute).Truncate(time.Second)
|
||||
store.Sessions[0].LastSeenAt = originalLastSeen
|
||||
if err := settings.SaveSessions(configDir, store, key); err != nil {
|
||||
t.Fatalf("SaveSessions: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
|
||||
request.AddCookie(&http.Cookie{Name: SessionCookieName, Value: record.ID})
|
||||
lookupRec := httptest.NewRecorder()
|
||||
|
||||
got, err := manager.LookupValidSession(lookupRec, request, security, false)
|
||||
if err != nil {
|
||||
t.Fatalf("LookupValidSession: %v", err)
|
||||
}
|
||||
if got.ID != record.ID {
|
||||
t.Fatalf("expected session id %q, got %q", record.ID, got.ID)
|
||||
}
|
||||
|
||||
store, err = settings.LoadSessions(configDir, key)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSessions after lookup: %v", err)
|
||||
}
|
||||
if len(store.Sessions) != 1 {
|
||||
t.Fatalf("expected 1 session, got %d", len(store.Sessions))
|
||||
}
|
||||
if !store.Sessions[0].LastSeenAt.Equal(originalLastSeen) {
|
||||
t.Fatalf(
|
||||
"expected LastSeenAt %v unchanged, got %v",
|
||||
originalLastSeen,
|
||||
store.Sessions[0].LastSeenAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupValidSessionIdleWithoutTouchStillIdle(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
key := testSessionKey()
|
||||
manager := NewSessionManager(configDir, key)
|
||||
security := settings.DefaultSecuritySettings()
|
||||
security.IdleTimeoutMinutes = 5
|
||||
|
||||
createRec := httptest.NewRecorder()
|
||||
record, err := manager.CreateSession(createRec, "user-1", security)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
store, err := settings.LoadSessions(configDir, key)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSessions: %v", err)
|
||||
}
|
||||
store.Sessions[0].LastSeenAt = time.Now().UTC().Add(-6 * time.Minute)
|
||||
if err := settings.SaveSessions(configDir, store, key); err != nil {
|
||||
t.Fatalf("SaveSessions: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
|
||||
request.AddCookie(&http.Cookie{Name: SessionCookieName, Value: record.ID})
|
||||
lookupRec := httptest.NewRecorder()
|
||||
|
||||
_, err = manager.LookupValidSession(lookupRec, request, security, false)
|
||||
if !errors.Is(err, ErrSessionIdle) {
|
||||
t.Fatalf("expected ErrSessionIdle, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func testSessionKey() []byte {
|
||||
key := make([]byte, 32)
|
||||
for index := range key {
|
||||
key[index] = byte(index + 3)
|
||||
}
|
||||
return key
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
const defaultSSHRunTimeout = 5 * time.Minute
|
||||
|
||||
// SSHRunResult captures stdout, stderr, and exit status from a remote command.
|
||||
type SSHRunResult struct {
|
||||
Stdout string
|
||||
Stderr string
|
||||
ExitCode int
|
||||
}
|
||||
|
||||
// RunSSHShell runs a single shell command on the remote host over SSH.
|
||||
func RunSSHShell(
|
||||
hostIP string,
|
||||
username string,
|
||||
privateKeyPEM string,
|
||||
passphrase string,
|
||||
command string,
|
||||
env map[string]string,
|
||||
timeout time.Duration,
|
||||
) (SSHRunResult, error) {
|
||||
if strings.TrimSpace(command) == "" {
|
||||
return SSHRunResult{}, fmt.Errorf("command is required")
|
||||
}
|
||||
fullCommand := PrependEnvExports(env, command)
|
||||
return runSSHSession(hostIP, username, privateKeyPEM, passphrase, fullCommand, timeout)
|
||||
}
|
||||
|
||||
// RunSSHScript uploads and runs a multi-line script via bash -s on the remote host.
|
||||
// When requiresSudo is true, the remote command is "sudo -n bash -s".
|
||||
func RunSSHScript(
|
||||
hostIP string,
|
||||
username string,
|
||||
privateKeyPEM string,
|
||||
passphrase string,
|
||||
scriptBody string,
|
||||
env map[string]string,
|
||||
timeout time.Duration,
|
||||
requiresSudo bool,
|
||||
) (SSHRunResult, error) {
|
||||
if strings.TrimSpace(scriptBody) == "" {
|
||||
return SSHRunResult{}, fmt.Errorf("script body is required")
|
||||
}
|
||||
|
||||
hostIP = strings.TrimSpace(hostIP)
|
||||
username = strings.TrimSpace(username)
|
||||
if hostIP == "" {
|
||||
return SSHRunResult{}, fmt.Errorf("host IP is required")
|
||||
}
|
||||
if username == "" {
|
||||
return SSHRunResult{}, fmt.Errorf("username is required")
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = defaultSSHRunTimeout
|
||||
}
|
||||
|
||||
signer, err := parseSigner(privateKeyPEM, passphrase)
|
||||
if err != nil {
|
||||
return SSHRunResult{}, err
|
||||
}
|
||||
|
||||
config := &ssh.ClientConfig{
|
||||
User: username,
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.PublicKeys(signer),
|
||||
},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
Timeout: sshDialTimeout,
|
||||
}
|
||||
|
||||
address := net.JoinHostPort(hostIP, defaultSSHPort)
|
||||
client, err := ssh.Dial("tcp", address, config)
|
||||
if err != nil {
|
||||
return SSHRunResult{}, fmt.Errorf("ssh dial %s: %w", address, err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return SSHRunResult{}, fmt.Errorf("ssh session: %w", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
var stdoutBuf bytes.Buffer
|
||||
var stderrBuf bytes.Buffer
|
||||
session.Stdout = &stdoutBuf
|
||||
session.Stderr = &stderrBuf
|
||||
session.Stdin = strings.NewReader(scriptBody)
|
||||
|
||||
bashCommand := "bash -s"
|
||||
if requiresSudo {
|
||||
bashCommand = "sudo -n bash -s"
|
||||
}
|
||||
remoteCommand := PrependEnvExports(env, bashCommand)
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- session.Run(remoteCommand)
|
||||
}()
|
||||
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
return finishSSHResult(stdoutBuf.String(), stderrBuf.String(), err)
|
||||
case <-timer.C:
|
||||
_ = session.Close()
|
||||
return SSHRunResult{
|
||||
Stdout: stdoutBuf.String(),
|
||||
Stderr: stderrBuf.String(),
|
||||
ExitCode: -1,
|
||||
}, fmt.Errorf("ssh command timed out after %s", timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func runSSHSession(
|
||||
hostIP string,
|
||||
username string,
|
||||
privateKeyPEM string,
|
||||
passphrase string,
|
||||
command string,
|
||||
timeout time.Duration,
|
||||
) (SSHRunResult, error) {
|
||||
hostIP = strings.TrimSpace(hostIP)
|
||||
username = strings.TrimSpace(username)
|
||||
if hostIP == "" {
|
||||
return SSHRunResult{}, fmt.Errorf("host IP is required")
|
||||
}
|
||||
if username == "" {
|
||||
return SSHRunResult{}, fmt.Errorf("username is required")
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = defaultSSHRunTimeout
|
||||
}
|
||||
|
||||
signer, err := parseSigner(privateKeyPEM, passphrase)
|
||||
if err != nil {
|
||||
return SSHRunResult{}, err
|
||||
}
|
||||
|
||||
config := &ssh.ClientConfig{
|
||||
User: username,
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.PublicKeys(signer),
|
||||
},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
Timeout: sshDialTimeout,
|
||||
}
|
||||
|
||||
address := net.JoinHostPort(hostIP, defaultSSHPort)
|
||||
client, err := ssh.Dial("tcp", address, config)
|
||||
if err != nil {
|
||||
return SSHRunResult{}, fmt.Errorf("ssh dial %s: %w", address, err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return SSHRunResult{}, fmt.Errorf("ssh session: %w", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
var stdoutBuf bytes.Buffer
|
||||
var stderrBuf bytes.Buffer
|
||||
session.Stdout = &stdoutBuf
|
||||
session.Stderr = &stderrBuf
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- session.Run(command)
|
||||
}()
|
||||
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
return finishSSHResult(stdoutBuf.String(), stderrBuf.String(), err)
|
||||
case <-timer.C:
|
||||
_ = session.Close()
|
||||
return SSHRunResult{
|
||||
Stdout: stdoutBuf.String(),
|
||||
Stderr: stderrBuf.String(),
|
||||
ExitCode: -1,
|
||||
}, fmt.Errorf("ssh command timed out after %s", timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func finishSSHResult(stdout string, stderr string, err error) (SSHRunResult, error) {
|
||||
result := SSHRunResult{
|
||||
Stdout: stdout,
|
||||
Stderr: stderr,
|
||||
ExitCode: 0,
|
||||
}
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
if exitErr, ok := err.(*ssh.ExitError); ok {
|
||||
result.ExitCode = exitErr.ExitStatus()
|
||||
return result, nil
|
||||
}
|
||||
result.ExitCode = -1
|
||||
return result, err
|
||||
}
|
||||
|
||||
// PrependEnvExports builds the remote command string with optional export PREFIX; command.
|
||||
func PrependEnvExports(env map[string]string, command string) string {
|
||||
if len(env) == 0 {
|
||||
return command
|
||||
}
|
||||
var builder strings.Builder
|
||||
for name, value := range env {
|
||||
builder.WriteString("export ")
|
||||
builder.WriteString(shellQuote(name))
|
||||
builder.WriteString("=")
|
||||
builder.WriteString(shellQuote(value))
|
||||
builder.WriteString("; ")
|
||||
}
|
||||
builder.WriteString(command)
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func shellQuote(value string) string {
|
||||
return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'"
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||
)
|
||||
|
||||
const schedulerTickInterval = 15 * time.Second
|
||||
|
||||
// Result is the outcome of a node health check.
|
||||
type Result struct {
|
||||
PingOK bool
|
||||
SSHOK bool
|
||||
OK bool
|
||||
Message string
|
||||
CheckedAt time.Time
|
||||
}
|
||||
|
||||
// Checker runs and schedules per-node health checks.
|
||||
type Checker struct {
|
||||
ConfigDir string
|
||||
Key []byte
|
||||
|
||||
mu sync.Mutex
|
||||
running map[string]struct{}
|
||||
stopCh chan struct{}
|
||||
stopped chan struct{}
|
||||
tickerOn bool
|
||||
|
||||
// Optional overrides for tests.
|
||||
PingFn func(hostIP string) auth.PingResult
|
||||
SSHFn func(hostIP string, username string, privateKeyPEM string, passphrase string) error
|
||||
NowFn func() time.Time
|
||||
}
|
||||
|
||||
// NewChecker creates a health checker for the given config directory and key.
|
||||
func NewChecker(configDir string, key []byte) *Checker {
|
||||
return &Checker{
|
||||
ConfigDir: configDir,
|
||||
Key: key,
|
||||
running: map[string]struct{}{},
|
||||
stopCh: make(chan struct{}),
|
||||
stopped: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// StartScheduler begins the background health-check ticker.
|
||||
func (checker *Checker) StartScheduler() {
|
||||
checker.mu.Lock()
|
||||
if checker.tickerOn {
|
||||
checker.mu.Unlock()
|
||||
return
|
||||
}
|
||||
checker.tickerOn = true
|
||||
checker.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
defer close(checker.stopped)
|
||||
ticker := time.NewTicker(schedulerTickInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-checker.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
checker.tickDueChecks()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// StopScheduler stops the background ticker and waits for it to exit.
|
||||
func (checker *Checker) StopScheduler() {
|
||||
checker.mu.Lock()
|
||||
if !checker.tickerOn {
|
||||
checker.mu.Unlock()
|
||||
return
|
||||
}
|
||||
checker.tickerOn = false
|
||||
checker.mu.Unlock()
|
||||
close(checker.stopCh)
|
||||
<-checker.stopped
|
||||
}
|
||||
|
||||
func (checker *Checker) tryLockNode(nodeID string) bool {
|
||||
checker.mu.Lock()
|
||||
defer checker.mu.Unlock()
|
||||
if _, ok := checker.running[nodeID]; ok {
|
||||
return false
|
||||
}
|
||||
checker.running[nodeID] = struct{}{}
|
||||
return true
|
||||
}
|
||||
|
||||
func (checker *Checker) unlockNode(nodeID string) {
|
||||
checker.mu.Lock()
|
||||
defer checker.mu.Unlock()
|
||||
delete(checker.running, nodeID)
|
||||
}
|
||||
|
||||
func (checker *Checker) now() time.Time {
|
||||
if checker.NowFn != nil {
|
||||
return checker.NowFn()
|
||||
}
|
||||
return time.Now().UTC()
|
||||
}
|
||||
|
||||
func (checker *Checker) tickDueChecks() {
|
||||
if len(checker.Key) == 0 {
|
||||
return
|
||||
}
|
||||
store, err := settings.LoadNodesOrEmpty(checker.ConfigDir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
now := checker.now()
|
||||
for _, node := range store.Nodes {
|
||||
if !IsHealthCheckDue(node, now) {
|
||||
continue
|
||||
}
|
||||
nodeID := node.ID
|
||||
go func() {
|
||||
_, _ = checker.CheckNodeByID(nodeID, "scheduler")
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// IsHealthCheckDue reports whether a node should be probed now.
|
||||
func IsHealthCheckDue(node settings.Node, now time.Time) bool {
|
||||
if node.HealthCheckIntervalSeconds <= 0 {
|
||||
return false
|
||||
}
|
||||
if node.HealthLastCheckedAt == nil {
|
||||
return true
|
||||
}
|
||||
elapsed := now.Sub(node.HealthLastCheckedAt.UTC())
|
||||
return elapsed >= time.Duration(node.HealthCheckIntervalSeconds)*time.Second
|
||||
}
|
||||
|
||||
// CheckNodeByID loads credentials, runs ping + SSH, and persists the result.
|
||||
func (checker *Checker) CheckNodeByID(nodeID string, actor string) (settings.Node, error) {
|
||||
if !checker.tryLockNode(nodeID) {
|
||||
store, err := settings.LoadNodesOrEmpty(checker.ConfigDir)
|
||||
if err != nil {
|
||||
return settings.Node{}, err
|
||||
}
|
||||
for _, node := range store.Nodes {
|
||||
if node.ID == nodeID {
|
||||
return node, fmt.Errorf("health check already running for node")
|
||||
}
|
||||
}
|
||||
return settings.Node{}, fmt.Errorf("node not found")
|
||||
}
|
||||
defer checker.unlockNode(nodeID)
|
||||
|
||||
if len(checker.Key) == 0 {
|
||||
return settings.Node{}, fmt.Errorf("%s is not set", settings.ConfigKeyEnvVar)
|
||||
}
|
||||
|
||||
store, err := settings.LoadNodesOrEmpty(checker.ConfigDir)
|
||||
if err != nil {
|
||||
return settings.Node{}, err
|
||||
}
|
||||
|
||||
nodeIndex := -1
|
||||
var node settings.Node
|
||||
for index, candidate := range store.Nodes {
|
||||
if candidate.ID != nodeID {
|
||||
continue
|
||||
}
|
||||
node = candidate
|
||||
nodeIndex = index
|
||||
break
|
||||
}
|
||||
if nodeIndex < 0 {
|
||||
return settings.Node{}, fmt.Errorf("node not found")
|
||||
}
|
||||
|
||||
keyStore, err := settings.LoadNodeKeysOrEmpty(checker.ConfigDir, checker.Key)
|
||||
if err != nil {
|
||||
return settings.Node{}, err
|
||||
}
|
||||
|
||||
var keyEntry settings.NodeKeyEntry
|
||||
keyFound := false
|
||||
for _, entry := range keyStore.Keys {
|
||||
if entry.NodeID == node.ID {
|
||||
keyEntry = entry
|
||||
keyFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !keyFound {
|
||||
return settings.Node{}, fmt.Errorf("private key not found for node")
|
||||
}
|
||||
|
||||
result := checker.runChecks(node, keyEntry)
|
||||
updated := applyHealthResult(node, result)
|
||||
store.Nodes[nodeIndex] = updated
|
||||
if err := settings.SaveNodes(checker.ConfigDir, store); err != nil {
|
||||
return settings.Node{}, err
|
||||
}
|
||||
|
||||
_ = appendHealthAudit(checker.ConfigDir, actor, updated, result.Message)
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func (checker *Checker) runChecks(node settings.Node, keyEntry settings.NodeKeyEntry) Result {
|
||||
checkedAt := checker.now()
|
||||
pingFn := checker.PingFn
|
||||
if pingFn == nil {
|
||||
pingFn = auth.PingHost
|
||||
}
|
||||
sshFn := checker.SSHFn
|
||||
if sshFn == nil {
|
||||
sshFn = auth.TestSSHConnection
|
||||
}
|
||||
|
||||
pingResult := pingFn(node.HostIP)
|
||||
sshErr := sshFn(node.HostIP, node.Username, keyEntry.PrivateKey, keyEntry.Passphrase)
|
||||
sshOK := sshErr == nil
|
||||
|
||||
message := buildHealthMessage(pingResult, sshOK, sshErr)
|
||||
return Result{
|
||||
PingOK: pingResult.OK,
|
||||
SSHOK: sshOK,
|
||||
OK: pingResult.OK && sshOK,
|
||||
Message: message,
|
||||
CheckedAt: checkedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func buildHealthMessage(pingResult auth.PingResult, sshOK bool, sshErr error) string {
|
||||
var parts []string
|
||||
if pingResult.OK {
|
||||
parts = append(parts, fmt.Sprintf("ping ok (%s)", pingResult.Method))
|
||||
} else if pingResult.Error != nil {
|
||||
parts = append(parts, fmt.Sprintf("ping failed: %v", pingResult.Error))
|
||||
} else {
|
||||
parts = append(parts, "ping failed")
|
||||
}
|
||||
|
||||
if sshOK {
|
||||
parts = append(parts, "ssh ok")
|
||||
} else if sshErr != nil {
|
||||
parts = append(parts, fmt.Sprintf("ssh failed: %v", sshErr))
|
||||
} else {
|
||||
parts = append(parts, "ssh failed")
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s; %s", parts[0], parts[1])
|
||||
}
|
||||
|
||||
func applyHealthResult(node settings.Node, result Result) settings.Node {
|
||||
checkedAt := result.CheckedAt
|
||||
pingOK := result.PingOK
|
||||
sshOK := result.SSHOK
|
||||
healthOK := result.OK
|
||||
node.HealthLastCheckedAt = &checkedAt
|
||||
node.HealthPingOK = &pingOK
|
||||
node.HealthSSHOK = &sshOK
|
||||
node.HealthOK = &healthOK
|
||||
node.HealthMessage = result.Message
|
||||
return node
|
||||
}
|
||||
|
||||
func appendHealthAudit(configDir string, actor string, node settings.Node, detail string) error {
|
||||
eventID, err := auth.NewUUID()
|
||||
if err != nil {
|
||||
eventID = fmt.Sprintf("health-%d", time.Now().UnixNano())
|
||||
}
|
||||
return settings.AppendNodeAuditEvent(configDir, settings.NodeAuditEvent{
|
||||
ID: eventID,
|
||||
At: time.Now().UTC(),
|
||||
Action: settings.NodeAuditActionHealthCheck,
|
||||
Actor: actor,
|
||||
NodeID: node.ID,
|
||||
NodeName: node.Name,
|
||||
NodeKind: node.Kind,
|
||||
Detail: detail,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||
)
|
||||
|
||||
func TestIsHealthCheckDue(t *testing.T) {
|
||||
now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
t.Run("disabled", func(t *testing.T) {
|
||||
if IsHealthCheckDue(settings.Node{HealthCheckIntervalSeconds: 0}, now) {
|
||||
t.Fatal("expected not due")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("never checked", func(t *testing.T) {
|
||||
if !IsHealthCheckDue(settings.Node{HealthCheckIntervalSeconds: 60}, now) {
|
||||
t.Fatal("expected due")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("interval elapsed", func(t *testing.T) {
|
||||
checked := now.Add(-61 * time.Second)
|
||||
node := settings.Node{
|
||||
HealthCheckIntervalSeconds: 60,
|
||||
HealthLastCheckedAt: &checked,
|
||||
}
|
||||
if !IsHealthCheckDue(node, now) {
|
||||
t.Fatal("expected due")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("interval not elapsed", func(t *testing.T) {
|
||||
checked := now.Add(-30 * time.Second)
|
||||
node := settings.Node{
|
||||
HealthCheckIntervalSeconds: 60,
|
||||
HealthLastCheckedAt: &checked,
|
||||
}
|
||||
if IsHealthCheckDue(node, now) {
|
||||
t.Fatal("expected not due")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCheckNodeByIDPersistsResult(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
keyBytes := make([]byte, 32)
|
||||
for index := range keyBytes {
|
||||
keyBytes[index] = byte(index + 1)
|
||||
}
|
||||
|
||||
nodeID := "11111111-2222-4333-8444-555555555555"
|
||||
store := settings.NodeStore{
|
||||
Nodes: []settings.Node{{
|
||||
ID: nodeID,
|
||||
Kind: settings.NodeKindContainer,
|
||||
Name: "ct-1",
|
||||
HostIP: "10.9.9.9",
|
||||
Username: "clustercanvas",
|
||||
GroupName: "Administrators",
|
||||
KeyAlgo: "ed25519",
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}},
|
||||
}
|
||||
if err := settings.SaveNodes(dir, store); err != nil {
|
||||
t.Fatalf("SaveNodes: %v", err)
|
||||
}
|
||||
if err := settings.SaveNodeKeys(dir, settings.NodeKeyStore{
|
||||
Keys: []settings.NodeKeyEntry{{
|
||||
NodeID: nodeID,
|
||||
PrivateKey: "dummy-key",
|
||||
Algorithm: "ed25519",
|
||||
}},
|
||||
}, keyBytes); err != nil {
|
||||
t.Fatalf("SaveNodeKeys: %v", err)
|
||||
}
|
||||
|
||||
checker := NewChecker(dir, keyBytes)
|
||||
checker.NowFn = func() time.Time {
|
||||
return time.Date(2026, 7, 19, 15, 0, 0, 0, time.UTC)
|
||||
}
|
||||
checker.PingFn = func(hostIP string) auth.PingResult {
|
||||
return auth.PingResult{OK: true, Method: auth.PingMethodTCP}
|
||||
}
|
||||
checker.SSHFn = func(hostIP string, username string, privateKeyPEM string, passphrase string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
updated, err := checker.CheckNodeByID(nodeID, "tester")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckNodeByID: %v", err)
|
||||
}
|
||||
if updated.HealthOK == nil || !*updated.HealthOK {
|
||||
t.Fatalf("health_ok = %#v", updated.HealthOK)
|
||||
}
|
||||
if updated.HealthPingOK == nil || !*updated.HealthPingOK {
|
||||
t.Fatal("expected ping ok")
|
||||
}
|
||||
if updated.HealthSSHOK == nil || !*updated.HealthSSHOK {
|
||||
t.Fatal("expected ssh ok")
|
||||
}
|
||||
if updated.HealthMessage == "" {
|
||||
t.Fatal("expected message")
|
||||
}
|
||||
|
||||
loaded, err := settings.LoadNodes(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadNodes: %v", err)
|
||||
}
|
||||
if loaded.Nodes[0].HealthOK == nil || !*loaded.Nodes[0].HealthOK {
|
||||
t.Fatal("persisted health_ok missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNodeByIDRecordsFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
keyBytes := make([]byte, 32)
|
||||
for index := range keyBytes {
|
||||
keyBytes[index] = byte(index + 3)
|
||||
}
|
||||
|
||||
nodeID := "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
|
||||
if err := settings.SaveNodes(dir, settings.NodeStore{
|
||||
Nodes: []settings.Node{{
|
||||
ID: nodeID,
|
||||
Kind: settings.NodeKindVM,
|
||||
Name: "vm-1",
|
||||
HostIP: "10.8.8.8",
|
||||
Username: "clustercanvas",
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveNodes: %v", err)
|
||||
}
|
||||
if err := settings.SaveNodeKeys(dir, settings.NodeKeyStore{
|
||||
Keys: []settings.NodeKeyEntry{{
|
||||
NodeID: nodeID,
|
||||
PrivateKey: "dummy-key",
|
||||
Algorithm: "ed25519",
|
||||
}},
|
||||
}, keyBytes); err != nil {
|
||||
t.Fatalf("SaveNodeKeys: %v", err)
|
||||
}
|
||||
|
||||
checker := NewChecker(dir, keyBytes)
|
||||
checker.PingFn = func(hostIP string) auth.PingResult {
|
||||
return auth.PingResult{OK: false, Method: auth.PingMethodTCP, Error: errors.New("unreachable")}
|
||||
}
|
||||
checker.SSHFn = func(hostIP string, username string, privateKeyPEM string, passphrase string) error {
|
||||
return errors.New("ssh dial failed")
|
||||
}
|
||||
|
||||
updated, err := checker.CheckNodeByID(nodeID, "tester")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckNodeByID: %v", err)
|
||||
}
|
||||
if updated.HealthOK == nil || *updated.HealthOK {
|
||||
t.Fatalf("expected health failure, got %#v", updated.HealthOK)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// ValidateCondition checks that expr is empty or a supported comparison.
|
||||
func ValidateCondition(expr string) error {
|
||||
trimmed := strings.TrimSpace(expr)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := parseCondition(trimmed)
|
||||
return err
|
||||
}
|
||||
|
||||
const skipDetailMaxValueLen = 80
|
||||
|
||||
// EvaluateCondition evaluates a run-if expression against the current var map.
|
||||
// Empty expr is always true. Eval failures return false with an error.
|
||||
// On success, detail is a compact comparison (e.g. "$UpdateCount=3 >= 1").
|
||||
// On a false result, detail is a human-readable skip explanation.
|
||||
func EvaluateCondition(expr string, vars map[string]string) (ok bool, detail string, err error) {
|
||||
trimmed := strings.TrimSpace(expr)
|
||||
if trimmed == "" {
|
||||
return true, "", nil
|
||||
}
|
||||
parsed, err := parseCondition(trimmed)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
detail = formatEvaluatedCondition(parsed, vars)
|
||||
leftValue := resolveOperand(parsed.left, vars)
|
||||
rightValue := resolveOperand(parsed.right, vars)
|
||||
ok, err = compareOperands(leftValue, parsed.op, rightValue)
|
||||
if err != nil {
|
||||
return false, detail, err
|
||||
}
|
||||
if !ok {
|
||||
detail = formatSkipDetail(parsed, vars)
|
||||
}
|
||||
return ok, detail, nil
|
||||
}
|
||||
|
||||
func formatEvaluatedCondition(parsed parsedCondition, vars map[string]string) string {
|
||||
return fmt.Sprintf("%s %s %s",
|
||||
formatOperandForDisplay(parsed.left, vars),
|
||||
parsed.op,
|
||||
formatOperandForDisplay(parsed.right, vars),
|
||||
)
|
||||
}
|
||||
|
||||
// formatSkipDetail builds a user-facing explanation when a run-if condition is false.
|
||||
func formatSkipDetail(parsed parsedCondition, vars map[string]string) string {
|
||||
rightDisplay := formatOperandValueForSkip(parsed.right, vars)
|
||||
if parsed.left.kind == operandVar {
|
||||
value := ""
|
||||
if vars != nil {
|
||||
value = vars[parsed.left.varName]
|
||||
}
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fmt.Sprintf("$%s was unset, which does not meet run condition (%s %s)",
|
||||
parsed.left.varName, parsed.op, rightDisplay)
|
||||
}
|
||||
return fmt.Sprintf("$%s was %s, which does not meet run condition (%s %s)",
|
||||
parsed.left.varName, truncateForSkipDetail(value), parsed.op, rightDisplay)
|
||||
}
|
||||
return fmt.Sprintf("run condition not met (%s %s %s)",
|
||||
formatOperandValueForSkip(parsed.left, vars),
|
||||
parsed.op,
|
||||
rightDisplay,
|
||||
)
|
||||
}
|
||||
|
||||
func formatOperandForDisplay(operand conditionOperand, vars map[string]string) string {
|
||||
if operand.kind == operandVar {
|
||||
value := ""
|
||||
if vars != nil {
|
||||
value = vars[operand.varName]
|
||||
}
|
||||
return fmt.Sprintf("$%s=%s", operand.varName, value)
|
||||
}
|
||||
return operand.literal
|
||||
}
|
||||
|
||||
func formatOperandValueForSkip(operand conditionOperand, vars map[string]string) string {
|
||||
if operand.kind == operandVar {
|
||||
value := ""
|
||||
if vars != nil {
|
||||
value = vars[operand.varName]
|
||||
}
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return "unset"
|
||||
}
|
||||
return truncateForSkipDetail(value)
|
||||
}
|
||||
return operand.literal
|
||||
}
|
||||
|
||||
func truncateForSkipDetail(value string) string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
runes := []rune(trimmed)
|
||||
if len(runes) <= skipDetailMaxValueLen {
|
||||
return trimmed
|
||||
}
|
||||
return string(runes[:skipDetailMaxValueLen]) + "…"
|
||||
}
|
||||
|
||||
type conditionOp string
|
||||
|
||||
const (
|
||||
opEQ conditionOp = "=="
|
||||
opNE conditionOp = "!="
|
||||
opGE conditionOp = ">="
|
||||
opLE conditionOp = "<="
|
||||
opGT conditionOp = ">"
|
||||
opLT conditionOp = "<"
|
||||
)
|
||||
|
||||
type conditionOperand struct {
|
||||
kind operandKind
|
||||
varName string
|
||||
literal string
|
||||
isNumeric bool
|
||||
number float64
|
||||
}
|
||||
|
||||
type operandKind int
|
||||
|
||||
const (
|
||||
operandVar operandKind = iota
|
||||
operandLiteral
|
||||
)
|
||||
|
||||
type parsedCondition struct {
|
||||
left conditionOperand
|
||||
op conditionOp
|
||||
right conditionOperand
|
||||
}
|
||||
|
||||
func parseCondition(expr string) (parsedCondition, error) {
|
||||
remaining := strings.TrimSpace(expr)
|
||||
if strings.HasPrefix(strings.ToLower(remaining), "if ") || strings.EqualFold(remaining, "if") {
|
||||
if len(remaining) < 3 {
|
||||
return parsedCondition{}, errors.New("condition is empty after if")
|
||||
}
|
||||
remaining = strings.TrimSpace(remaining[2:])
|
||||
}
|
||||
if remaining == "" {
|
||||
return parsedCondition{}, errors.New("condition is empty")
|
||||
}
|
||||
|
||||
left, rest, err := parseOperand(remaining)
|
||||
if err != nil {
|
||||
return parsedCondition{}, err
|
||||
}
|
||||
rest = strings.TrimSpace(rest)
|
||||
op, afterOp, err := parseOperator(rest)
|
||||
if err != nil {
|
||||
return parsedCondition{}, err
|
||||
}
|
||||
right, trailing, err := parseOperand(strings.TrimSpace(afterOp))
|
||||
if err != nil {
|
||||
return parsedCondition{}, err
|
||||
}
|
||||
if strings.TrimSpace(trailing) != "" {
|
||||
return parsedCondition{}, fmt.Errorf("unexpected trailing input %q", strings.TrimSpace(trailing))
|
||||
}
|
||||
return parsedCondition{left: left, op: op, right: right}, nil
|
||||
}
|
||||
|
||||
func parseOperator(input string) (conditionOp, string, error) {
|
||||
operators := []conditionOp{opEQ, opNE, opGE, opLE, opGT, opLT}
|
||||
for _, op := range operators {
|
||||
prefix := string(op)
|
||||
if strings.HasPrefix(input, prefix) {
|
||||
return op, input[len(prefix):], nil
|
||||
}
|
||||
}
|
||||
return "", "", errors.New("expected comparison operator (==, !=, >=, <=, >, <)")
|
||||
}
|
||||
|
||||
func parseOperand(input string) (conditionOperand, string, error) {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return conditionOperand{}, "", errors.New("expected operand")
|
||||
}
|
||||
|
||||
if input[0] == '$' {
|
||||
name, rest, err := parseDollarName(input)
|
||||
if err != nil {
|
||||
return conditionOperand{}, "", err
|
||||
}
|
||||
if err := ValidateVariableName(name); err != nil {
|
||||
return conditionOperand{}, "", err
|
||||
}
|
||||
return conditionOperand{kind: operandVar, varName: name}, rest, nil
|
||||
}
|
||||
|
||||
if input[0] == '"' || input[0] == '\'' {
|
||||
quote := input[0]
|
||||
var builder strings.Builder
|
||||
i := 1
|
||||
for i < len(input) {
|
||||
ch := input[i]
|
||||
if ch == quote {
|
||||
return conditionOperand{
|
||||
kind: operandLiteral,
|
||||
literal: builder.String(),
|
||||
}, input[i+1:], nil
|
||||
}
|
||||
builder.WriteByte(ch)
|
||||
i++
|
||||
}
|
||||
return conditionOperand{}, "", errors.New("unclosed string literal")
|
||||
}
|
||||
|
||||
end := 0
|
||||
for end < len(input) {
|
||||
ch := rune(input[end])
|
||||
if unicode.IsSpace(ch) {
|
||||
break
|
||||
}
|
||||
// Stop before a comparison operator that is not part of a number sign.
|
||||
if end > 0 && (ch == '=' || ch == '!' || ch == '>' || ch == '<') {
|
||||
break
|
||||
}
|
||||
if end == 0 && (ch == '=' || ch == '!' || ch == '>' || ch == '<') {
|
||||
break
|
||||
}
|
||||
end++
|
||||
}
|
||||
if end == 0 {
|
||||
return conditionOperand{}, "", errors.New("expected operand")
|
||||
}
|
||||
token := input[:end]
|
||||
if number, err := strconv.ParseFloat(token, 64); err == nil {
|
||||
return conditionOperand{
|
||||
kind: operandLiteral,
|
||||
literal: token,
|
||||
isNumeric: true,
|
||||
number: number,
|
||||
}, input[end:], nil
|
||||
}
|
||||
return conditionOperand{kind: operandLiteral, literal: token}, input[end:], nil
|
||||
}
|
||||
|
||||
func parseDollarName(input string) (string, string, error) {
|
||||
if !strings.HasPrefix(input, "$") {
|
||||
return "", "", errors.New("expected $variable")
|
||||
}
|
||||
rest := input[1:]
|
||||
if rest == "" {
|
||||
return "", "", errors.New("expected variable name after $")
|
||||
}
|
||||
if rest[0] == '{' {
|
||||
closeIdx := strings.IndexByte(rest, '}')
|
||||
if closeIdx < 0 {
|
||||
return "", "", errors.New("unclosed ${variable}")
|
||||
}
|
||||
name := rest[1:closeIdx]
|
||||
return name, rest[closeIdx+1:], nil
|
||||
}
|
||||
end := 0
|
||||
for end < len(rest) {
|
||||
ch := rest[end]
|
||||
if (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '_' {
|
||||
end++
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
if end == 0 {
|
||||
return "", "", errors.New("expected variable name after $")
|
||||
}
|
||||
return rest[:end], rest[end:], nil
|
||||
}
|
||||
|
||||
func resolveOperand(operand conditionOperand, vars map[string]string) conditionOperand {
|
||||
if operand.kind != operandVar {
|
||||
return operand
|
||||
}
|
||||
value := ""
|
||||
if vars != nil {
|
||||
value = vars[operand.varName]
|
||||
}
|
||||
resolved := conditionOperand{kind: operandLiteral, literal: value}
|
||||
if number, err := strconv.ParseFloat(strings.TrimSpace(value), 64); err == nil && strings.TrimSpace(value) != "" {
|
||||
resolved.isNumeric = true
|
||||
resolved.number = number
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func compareOperands(left conditionOperand, op conditionOp, right conditionOperand) (bool, error) {
|
||||
if left.isNumeric && right.isNumeric {
|
||||
switch op {
|
||||
case opEQ:
|
||||
return left.number == right.number, nil
|
||||
case opNE:
|
||||
return left.number != right.number, nil
|
||||
case opGE:
|
||||
return left.number >= right.number, nil
|
||||
case opLE:
|
||||
return left.number <= right.number, nil
|
||||
case opGT:
|
||||
return left.number > right.number, nil
|
||||
case opLT:
|
||||
return left.number < right.number, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Numeric ops with a non-numeric side evaluate to false (e.g. unset >= 1).
|
||||
if op == opGE || op == opLE || op == opGT || op == opLT {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
switch op {
|
||||
case opEQ:
|
||||
return left.literal == right.literal, nil
|
||||
case opNE:
|
||||
return left.literal != right.literal, nil
|
||||
default:
|
||||
return false, fmt.Errorf("unsupported operator %q", op)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateCondition(t *testing.T) {
|
||||
if err := ValidateCondition(""); err != nil {
|
||||
t.Fatalf("empty: %v", err)
|
||||
}
|
||||
if err := ValidateCondition("if $UpdateCount >= 1"); err != nil {
|
||||
t.Fatalf("valid: %v", err)
|
||||
}
|
||||
if err := ValidateCondition("$X == \"ok\""); err != nil {
|
||||
t.Fatalf("string: %v", err)
|
||||
}
|
||||
if err := ValidateCondition("not a condition"); err == nil {
|
||||
t.Fatal("expected invalid condition error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateCondition(t *testing.T) {
|
||||
vars := map[string]string{"UpdateCount": "3", "Status": "ready"}
|
||||
|
||||
ok, detail, err := EvaluateCondition("if $UpdateCount >= 1", vars)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("expected true, got %v %v", ok, err)
|
||||
}
|
||||
if detail != "$UpdateCount=3 >= 1" {
|
||||
t.Fatalf("detail = %q", detail)
|
||||
}
|
||||
|
||||
ok, detail, err = EvaluateCondition("$UpdateCount >= 10", vars)
|
||||
if err != nil || ok {
|
||||
t.Fatalf("expected false, got %v %v", ok, err)
|
||||
}
|
||||
wantFail := "$UpdateCount was 3, which does not meet run condition (>= 10)"
|
||||
if detail != wantFail {
|
||||
t.Fatalf("detail = %q, want %q", detail, wantFail)
|
||||
}
|
||||
|
||||
ok, detail, err = EvaluateCondition("$Missing >= 1", vars)
|
||||
if err != nil || ok {
|
||||
t.Fatalf("unset numeric compare should be false, got %v %v", ok, err)
|
||||
}
|
||||
wantUnset := "$Missing was unset, which does not meet run condition (>= 1)"
|
||||
if detail != wantUnset {
|
||||
t.Fatalf("detail = %q, want %q", detail, wantUnset)
|
||||
}
|
||||
|
||||
ok, detail, err = EvaluateCondition("$Status == ready", vars)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("string eq expected true, got %v %v", ok, err)
|
||||
}
|
||||
if detail != "$Status=ready == ready" {
|
||||
t.Fatalf("detail = %q", detail)
|
||||
}
|
||||
|
||||
ok, detail, err = EvaluateCondition("", vars)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("empty should be true, got %v %v", ok, err)
|
||||
}
|
||||
if detail != "" {
|
||||
t.Fatalf("empty detail = %q", detail)
|
||||
}
|
||||
|
||||
ok, detail, err = EvaluateCondition("$Status != 'busy'", vars)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("quoted ne expected true, got %v %v", ok, err)
|
||||
}
|
||||
if detail != "$Status=ready != busy" {
|
||||
t.Fatalf("detail = %q", detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateConditionDetailUnset(t *testing.T) {
|
||||
ok, detail, err := EvaluateCondition("if $UpdateCount >= 10", map[string]string{})
|
||||
if err != nil || ok {
|
||||
t.Fatalf("expected false, got %v %v", ok, err)
|
||||
}
|
||||
want := "$UpdateCount was unset, which does not meet run condition (>= 10)"
|
||||
if detail != want {
|
||||
t.Fatalf("detail = %q, want %q", detail, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateConditionSkipDetailZero(t *testing.T) {
|
||||
ok, detail, err := EvaluateCondition("if $UpdateCount >= 1", map[string]string{"UpdateCount": "0"})
|
||||
if err != nil || ok {
|
||||
t.Fatalf("expected false, got %v %v", ok, err)
|
||||
}
|
||||
want := "$UpdateCount was 0, which does not meet run condition (>= 1)"
|
||||
if detail != want {
|
||||
t.Fatalf("detail = %q, want %q", detail, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateConditionSkipDetailTruncatesLongValue(t *testing.T) {
|
||||
longValue := strings.Repeat("x", skipDetailMaxValueLen+20)
|
||||
ok, detail, err := EvaluateCondition("if $UpdateCount >= 1", map[string]string{"UpdateCount": longValue})
|
||||
if err != nil || ok {
|
||||
t.Fatalf("expected false, got %v %v", ok, err)
|
||||
}
|
||||
truncated := truncateForSkipDetail(longValue)
|
||||
want := "$UpdateCount was " + truncated + ", which does not meet run condition (>= 1)"
|
||||
if detail != want {
|
||||
t.Fatalf("detail = %q, want %q", detail, want)
|
||||
}
|
||||
if !strings.HasSuffix(truncated, "…") {
|
||||
t.Fatalf("expected truncation ellipsis in %q", truncated)
|
||||
}
|
||||
if len([]rune(truncated)) != skipDetailMaxValueLen+1 {
|
||||
t.Fatalf("truncated length = %d", len([]rune(truncated)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,553 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/runner/widgetparse"
|
||||
"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 := mergeActionEnv(action.Env, item.Env)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// mergeActionEnv returns library env with item overrides (item wins on name clash).
|
||||
func mergeActionEnv(
|
||||
libraryEnv []settings.ActionEnvVar,
|
||||
itemEnv []settings.ActionEnvVar,
|
||||
) []settings.ActionEnvVar {
|
||||
merged := make([]settings.ActionEnvVar, 0, len(libraryEnv)+len(itemEnv))
|
||||
indexByName := map[string]int{}
|
||||
for _, envVar := range libraryEnv {
|
||||
name := strings.TrimSpace(envVar.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
indexByName[name] = len(merged)
|
||||
merged = append(merged, settings.ActionEnvVar{Name: name, Value: envVar.Value})
|
||||
}
|
||||
for _, envVar := range itemEnv {
|
||||
name := strings.TrimSpace(envVar.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if index, exists := indexByName[name]; exists {
|
||||
merged[index].Value = envVar.Value
|
||||
continue
|
||||
}
|
||||
indexByName[name] = len(merged)
|
||||
merged = append(merged, settings.ActionEnvVar{Name: name, Value: envVar.Value})
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
if item.Source == settings.ActionItemSourceLibrary {
|
||||
_ = persistWidgetSnapshot(
|
||||
executor.ConfigDir,
|
||||
group,
|
||||
item,
|
||||
libraryStore.Actions,
|
||||
itemResult,
|
||||
envMap,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
func persistWidgetSnapshot(
|
||||
configDir string,
|
||||
group settings.NodeActionGroup,
|
||||
item settings.NodeActionItem,
|
||||
library []settings.Action,
|
||||
itemResult settings.ActionItemRunResult,
|
||||
envMap map[string]string,
|
||||
) error {
|
||||
var libraryAction *settings.Action
|
||||
for index := range library {
|
||||
if library[index].ID == item.LibraryActionID {
|
||||
libraryAction = &library[index]
|
||||
break
|
||||
}
|
||||
}
|
||||
if libraryAction == nil || !libraryAction.ShowWidget {
|
||||
return nil
|
||||
}
|
||||
widgetType := libraryAction.WidgetType
|
||||
if widgetType == settings.ActionWidgetTypeNone {
|
||||
widgetType = settings.ActionWidgetTypeRaw
|
||||
}
|
||||
|
||||
title := strings.TrimSpace(item.WidgetLabel)
|
||||
if title == "" {
|
||||
title = libraryAction.Name
|
||||
if mount := strings.TrimSpace(envMap["MOUNT"]); mount != "" && widgetType == settings.ActionWidgetTypeDisk {
|
||||
title = fmt.Sprintf("%s (%s)", libraryAction.Name, mount)
|
||||
}
|
||||
}
|
||||
|
||||
snapshot := settings.ActionWidgetSnapshot{
|
||||
NodeID: group.NodeID,
|
||||
ItemID: item.ID,
|
||||
GroupID: group.ID,
|
||||
LibraryActionID: item.LibraryActionID,
|
||||
WidgetType: widgetType,
|
||||
Title: title,
|
||||
UpdatedAt: itemResult.FinishedAt,
|
||||
ExitCode: itemResult.ExitCode,
|
||||
Error: itemResult.Error,
|
||||
RawStdout: itemResult.Stdout,
|
||||
}
|
||||
|
||||
if itemResult.Skipped {
|
||||
snapshot.Error = itemResult.SkipReason
|
||||
return settings.UpsertActionWidgetSnapshot(configDir, snapshot)
|
||||
}
|
||||
|
||||
mountHint := strings.TrimSpace(envMap["MOUNT"])
|
||||
if itemResult.ExitCode == 0 && itemResult.Error == "" {
|
||||
memory, disk, parseErr := widgetparse.ParseStdout(widgetType, itemResult.Stdout, mountHint)
|
||||
if parseErr != nil {
|
||||
snapshot.Error = parseErr.Error()
|
||||
} else {
|
||||
snapshot.Memory = memory
|
||||
snapshot.Disk = disk
|
||||
}
|
||||
} else if snapshot.Error == "" && itemResult.ExitCode != 0 {
|
||||
snapshot.Error = fmt.Sprintf("exit code %d", itemResult.ExitCode)
|
||||
}
|
||||
|
||||
return settings.UpsertActionWidgetSnapshot(configDir, snapshot)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||
)
|
||||
|
||||
func TestWrapShellWithSudo(t *testing.T) {
|
||||
if got := WrapShellWithSudo("/usr/bin/systemctl restart myservice", false); got != "/usr/bin/systemctl restart myservice" {
|
||||
t.Fatalf("without sudo = %q", got)
|
||||
}
|
||||
if got := WrapShellWithSudo("/usr/bin/systemctl restart myservice", true); got != "sudo -n /usr/bin/systemctl restart myservice" {
|
||||
t.Fatalf("with sudo = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScriptRemoteCommand(t *testing.T) {
|
||||
if got := ScriptRemoteCommand(false); got != "bash -s" {
|
||||
t.Fatalf("without sudo = %q", got)
|
||||
}
|
||||
if got := ScriptRemoteCommand(true); got != "sudo -n bash -s" {
|
||||
t.Fatalf("with sudo = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRemoteCommand(t *testing.T) {
|
||||
shellCmd := BuildRemoteCommand(settings.ActionKindShell, "apt update", true, nil)
|
||||
if shellCmd != "sudo -n apt update" {
|
||||
t.Fatalf("shell = %q", shellCmd)
|
||||
}
|
||||
scriptCmd := BuildRemoteCommand(settings.ActionKindScript, "apt update", true, nil)
|
||||
if scriptCmd != "sudo -n bash -s" {
|
||||
t.Fatalf("script = %q", scriptCmd)
|
||||
}
|
||||
withEnv := BuildRemoteCommand(
|
||||
settings.ActionKindShell,
|
||||
"apt update",
|
||||
true,
|
||||
map[string]string{"FOO": "bar"},
|
||||
)
|
||||
if withEnv != "export 'FOO'='bar'; sudo -n apt update" {
|
||||
t.Fatalf("with env = %q", withEnv)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveItemRequiresSudo(t *testing.T) {
|
||||
library := []settings.Action{
|
||||
{
|
||||
ID: "lib-1",
|
||||
Name: "Restart",
|
||||
Kind: settings.ActionKindShell,
|
||||
Body: "/usr/bin/systemctl restart myservice",
|
||||
RequiresSudo: true,
|
||||
Env: []settings.ActionEnvVar{},
|
||||
},
|
||||
}
|
||||
|
||||
resolved, err := ResolveItem(settings.NodeActionItem{
|
||||
ID: "i1",
|
||||
Source: settings.ActionItemSourceLibrary,
|
||||
LibraryActionID: "lib-1",
|
||||
}, library)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve library: %v", err)
|
||||
}
|
||||
if !resolved.RequiresSudo || resolved.Body != "/usr/bin/systemctl restart myservice" {
|
||||
t.Fatalf("library resolved = %#v", resolved)
|
||||
}
|
||||
|
||||
local, err := ResolveItem(settings.NodeActionItem{
|
||||
ID: "i2",
|
||||
Source: settings.ActionItemSourceLocal,
|
||||
Name: "Local restart",
|
||||
Kind: settings.ActionKindShell,
|
||||
Body: "/usr/bin/true",
|
||||
RequiresSudo: true,
|
||||
}, library)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve local: %v", err)
|
||||
}
|
||||
if !local.RequiresSudo {
|
||||
t.Fatalf("local resolved = %#v", local)
|
||||
}
|
||||
|
||||
noSudo, err := ResolveItem(settings.NodeActionItem{
|
||||
ID: "i3",
|
||||
Source: settings.ActionItemSourceLocal,
|
||||
Name: "Uptime",
|
||||
Kind: settings.ActionKindShell,
|
||||
Body: "uptime",
|
||||
}, library)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve no-sudo: %v", err)
|
||||
}
|
||||
if noSudo.RequiresSudo {
|
||||
t.Fatalf("expected RequiresSudo false, got %#v", noSudo)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
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{},
|
||||
},
|
||||
{
|
||||
ID: settings.BuiltinActionDiskUsageID,
|
||||
Name: "Disk usage",
|
||||
Kind: settings.ActionKindShell,
|
||||
Body: "df -h {{env.MOUNT}}",
|
||||
Env: []settings.ActionEnvVar{
|
||||
{Name: "MOUNT", Value: "/"},
|
||||
{Name: "EXTRA", Value: "lib"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
merged, err := ResolveItem(settings.NodeActionItem{
|
||||
ID: "i-disk",
|
||||
Source: settings.ActionItemSourceLibrary,
|
||||
LibraryActionID: settings.BuiltinActionDiskUsageID,
|
||||
Env: []settings.ActionEnvVar{
|
||||
{Name: "MOUNT", Value: "/data"},
|
||||
{Name: "ITEM_ONLY", Value: "yes"},
|
||||
},
|
||||
}, library)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveItem merge: %v", err)
|
||||
}
|
||||
envByName := map[string]string{}
|
||||
for _, envVar := range merged.Env {
|
||||
envByName[envVar.Name] = envVar.Value
|
||||
}
|
||||
if envByName["MOUNT"] != "/data" {
|
||||
t.Fatalf("MOUNT override = %#v", merged.Env)
|
||||
}
|
||||
if envByName["EXTRA"] != "lib" {
|
||||
t.Fatalf("library EXTRA lost: %#v", merged.Env)
|
||||
}
|
||||
if envByName["ITEM_ONLY"] != "yes" {
|
||||
t.Fatalf("item env missing: %#v", merged.Env)
|
||||
}
|
||||
|
||||
local, err := ResolveItem(settings.NodeActionItem{
|
||||
ID: "i2",
|
||||
Source: settings.ActionItemSourceLocal,
|
||||
Name: "Echo",
|
||||
Kind: settings.ActionKindShell,
|
||||
Body: "echo hi",
|
||||
Env: []settings.ActionEnvVar{},
|
||||
}, library)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveItem local: %v", err)
|
||||
}
|
||||
if local.Name != "Echo" {
|
||||
t.Fatalf("local = %#v", local)
|
||||
}
|
||||
|
||||
if _, err := ResolveItem(settings.NodeActionItem{
|
||||
Source: settings.ActionItemSourceLibrary,
|
||||
LibraryActionID: "missing",
|
||||
}, library); err == nil {
|
||||
t.Fatal("expected missing library action error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||
)
|
||||
|
||||
func TestRunScopedVariableSequence(t *testing.T) {
|
||||
vars := map[string]string{}
|
||||
|
||||
// Action 1 captures trimmed stdout into UpdateCount.
|
||||
captured := strings.TrimSpace("3\n")
|
||||
vars["UpdateCount"] = captured
|
||||
if vars["UpdateCount"] != "3" {
|
||||
t.Fatalf("captured = %q", vars["UpdateCount"])
|
||||
}
|
||||
|
||||
// Action 2 condition passes and body expands.
|
||||
ok, detail, err := EvaluateCondition("if $UpdateCount >= 1", vars)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("condition should pass: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if detail != "$UpdateCount=3 >= 1" {
|
||||
t.Fatalf("detail = %q", detail)
|
||||
}
|
||||
body := ExpandDollarVars("echo upgrades=$UpdateCount", vars)
|
||||
if body != "echo upgrades=3" {
|
||||
t.Fatalf("body = %q", body)
|
||||
}
|
||||
|
||||
// Action 3 condition fails → skipped.
|
||||
ok, detail, err = EvaluateCondition("if $UpdateCount >= 10", vars)
|
||||
if err != nil || ok {
|
||||
t.Fatalf("condition should fail: ok=%v err=%v", ok, err)
|
||||
}
|
||||
wantFail := "$UpdateCount was 3, which does not meet run condition (>= 10)"
|
||||
if detail != wantFail {
|
||||
t.Fatalf("detail = %q, want %q", detail, wantFail)
|
||||
}
|
||||
|
||||
// A fresh run starts with an empty map (no leak).
|
||||
nextRun := map[string]string{}
|
||||
ok, detail, err = EvaluateCondition("if $UpdateCount >= 1", nextRun)
|
||||
if err != nil || ok {
|
||||
t.Fatalf("unset in new run should be false: ok=%v err=%v", ok, err)
|
||||
}
|
||||
wantUnset := "$UpdateCount was unset, which does not meet run condition (>= 1)"
|
||||
if detail != wantUnset {
|
||||
t.Fatalf("detail = %q, want %q", detail, wantUnset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkipResultShape(t *testing.T) {
|
||||
item := settings.NodeActionItem{
|
||||
ID: "i1",
|
||||
Name: "Upgrade",
|
||||
RunIf: "if $UpdateCount >= 1",
|
||||
}
|
||||
vars := map[string]string{"UpdateCount": "0"}
|
||||
shouldRun, detail, condErr := EvaluateCondition(item.RunIf, vars)
|
||||
if condErr != nil {
|
||||
t.Fatalf("eval: %v", condErr)
|
||||
}
|
||||
if shouldRun {
|
||||
t.Fatal("expected skip")
|
||||
}
|
||||
skipReason := item.Name + " skipped: run condition was not met"
|
||||
if detail != "" {
|
||||
skipReason = item.Name + " skipped: " + detail
|
||||
}
|
||||
result := settings.ActionItemRunResult{
|
||||
ItemID: item.ID,
|
||||
ActionName: item.Name,
|
||||
Skipped: true,
|
||||
SkipReason: skipReason,
|
||||
ExitCode: 0,
|
||||
}
|
||||
want := "Upgrade skipped: $UpdateCount was 0, which does not meet run condition (>= 1)"
|
||||
if !result.Skipped || result.SkipReason != want {
|
||||
t.Fatalf("result = %#v, want SkipReason %q", result, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
variableNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||
// Matches $Name or ${Name}; only known names in the vars map are replaced.
|
||||
dollarVarPattern = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)`)
|
||||
)
|
||||
|
||||
// ValidateVariableName checks that name is a valid run-scoped variable identifier.
|
||||
func ValidateVariableName(name string) error {
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" {
|
||||
return errors.New("variable name is required")
|
||||
}
|
||||
if trimmed != name {
|
||||
return errors.New("variable name must not have leading or trailing spaces")
|
||||
}
|
||||
if !variableNamePattern.MatchString(trimmed) {
|
||||
return fmt.Errorf("invalid variable name %q", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExpandDollarVars replaces $Name and ${Name} when Name exists in vars.
|
||||
// Unknown identifiers are left unchanged for the remote shell.
|
||||
func ExpandDollarVars(text string, vars map[string]string) string {
|
||||
if vars == nil || len(vars) == 0 || text == "" {
|
||||
return text
|
||||
}
|
||||
return dollarVarPattern.ReplaceAllStringFunc(text, func(match string) string {
|
||||
submatches := dollarVarPattern.FindStringSubmatch(match)
|
||||
if len(submatches) < 3 {
|
||||
return match
|
||||
}
|
||||
name := submatches[1]
|
||||
if name == "" {
|
||||
name = submatches[2]
|
||||
}
|
||||
value, ok := vars[name]
|
||||
if !ok {
|
||||
return match
|
||||
}
|
||||
return value
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package runner
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateVariableName(t *testing.T) {
|
||||
if err := ValidateVariableName("UpdateCount"); err != nil {
|
||||
t.Fatalf("UpdateCount: %v", err)
|
||||
}
|
||||
if err := ValidateVariableName("_x"); err != nil {
|
||||
t.Fatalf("_x: %v", err)
|
||||
}
|
||||
if err := ValidateVariableName(""); err == nil {
|
||||
t.Fatal("expected empty name error")
|
||||
}
|
||||
if err := ValidateVariableName("1bad"); err == nil {
|
||||
t.Fatal("expected invalid name error")
|
||||
}
|
||||
if err := ValidateVariableName("bad-name"); err == nil {
|
||||
t.Fatal("expected invalid name error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandDollarVars(t *testing.T) {
|
||||
vars := map[string]string{"UpdateCount": "3", "Name": "web"}
|
||||
got := ExpandDollarVars("count=$UpdateCount host=${Name}-01 keep=$HOME", vars)
|
||||
want := "count=3 host=web-01 keep=$HOME"
|
||||
if got != want {
|
||||
t.Fatalf("got %q want %q", got, want)
|
||||
}
|
||||
if ExpandDollarVars("$Missing", nil) != "$Missing" {
|
||||
t.Fatal("nil vars should leave tokens unchanged")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package widgetparse
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||
)
|
||||
|
||||
var (
|
||||
whitespaceSplit = regexp.MustCompile(`\s+`)
|
||||
sizeUnitPattern = regexp.MustCompile(`(?i)^([0-9]*\.?[0-9]+)([KMGTP]?i?B?)$`)
|
||||
)
|
||||
|
||||
// ParseStdout parses action stdout into typed widget data for the given widget type.
|
||||
// mountHint selects a df row when widgetType is disk (e.g. "/" or "/data").
|
||||
func ParseStdout(
|
||||
widgetType settings.ActionWidgetType,
|
||||
stdout string,
|
||||
mountHint string,
|
||||
) (memory *settings.MemoryWidgetData, disk *settings.DiskWidgetData, parseErr error) {
|
||||
switch widgetType {
|
||||
case settings.ActionWidgetTypeMemory:
|
||||
data, err := ParseFreeM(stdout)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return &data, nil, nil
|
||||
case settings.ActionWidgetTypeDisk:
|
||||
data, err := ParseDfH(stdout, mountHint)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return nil, &data, nil
|
||||
case settings.ActionWidgetTypeRaw:
|
||||
return nil, nil, nil
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("unsupported widget type %q", widgetType)
|
||||
}
|
||||
}
|
||||
|
||||
// ParseFreeM parses `free -m` output and returns the Mem: line figures.
|
||||
func ParseFreeM(stdout string) (settings.MemoryWidgetData, error) {
|
||||
lines := strings.Split(stdout, "\n")
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
fields := whitespaceSplit.Split(trimmed, -1)
|
||||
if len(fields) < 4 {
|
||||
continue
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSuffix(fields[0], ":"), "Mem") {
|
||||
continue
|
||||
}
|
||||
total, err := strconv.ParseInt(fields[1], 10, 64)
|
||||
if err != nil {
|
||||
return settings.MemoryWidgetData{}, fmt.Errorf("parse free total: %w", err)
|
||||
}
|
||||
used, err := strconv.ParseInt(fields[2], 10, 64)
|
||||
if err != nil {
|
||||
return settings.MemoryWidgetData{}, fmt.Errorf("parse free used: %w", err)
|
||||
}
|
||||
free, err := strconv.ParseInt(fields[3], 10, 64)
|
||||
if err != nil {
|
||||
return settings.MemoryWidgetData{}, fmt.Errorf("parse free free: %w", err)
|
||||
}
|
||||
data := settings.MemoryWidgetData{
|
||||
Total: total,
|
||||
Used: used,
|
||||
Free: free,
|
||||
}
|
||||
if len(fields) > 4 {
|
||||
shared, err := strconv.ParseInt(fields[4], 10, 64)
|
||||
if err != nil {
|
||||
return settings.MemoryWidgetData{}, fmt.Errorf("parse free shared: %w", err)
|
||||
}
|
||||
data.Shared = shared
|
||||
}
|
||||
if len(fields) > 5 {
|
||||
buffCache, err := strconv.ParseInt(fields[5], 10, 64)
|
||||
if err != nil {
|
||||
return settings.MemoryWidgetData{}, fmt.Errorf("parse free buff/cache: %w", err)
|
||||
}
|
||||
data.BuffCache = buffCache
|
||||
}
|
||||
if len(fields) > 6 {
|
||||
available, err := strconv.ParseInt(fields[6], 10, 64)
|
||||
if err != nil {
|
||||
return settings.MemoryWidgetData{}, fmt.Errorf("parse free available: %w", err)
|
||||
}
|
||||
data.Available = available
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
return settings.MemoryWidgetData{}, fmt.Errorf("no Mem line found in free output")
|
||||
}
|
||||
|
||||
// ParseDfH parses `df -h` output. When mountHint is non-empty, prefers a row whose
|
||||
// Mounted on column matches that path; otherwise uses the first data row.
|
||||
func ParseDfH(stdout string, mountHint string) (settings.DiskWidgetData, error) {
|
||||
lines := strings.Split(stdout, "\n")
|
||||
var dataRows []settings.DiskWidgetData
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
fields := whitespaceSplit.Split(trimmed, -1)
|
||||
if len(fields) < 6 {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(fields[0], "Filesystem") {
|
||||
continue
|
||||
}
|
||||
// df wraps long filesystem names; prefer last 5 columns as Size Used Avail Use% Mounted.
|
||||
mountedOn := fields[len(fields)-1]
|
||||
usePercent := fields[len(fields)-2]
|
||||
avail := fields[len(fields)-3]
|
||||
used := fields[len(fields)-4]
|
||||
size := fields[len(fields)-5]
|
||||
filesystem := strings.Join(fields[:len(fields)-5], " ")
|
||||
if filesystem == "" {
|
||||
continue
|
||||
}
|
||||
row := settings.DiskWidgetData{
|
||||
Filesystem: filesystem,
|
||||
Size: size,
|
||||
Used: used,
|
||||
Avail: avail,
|
||||
UsePercent: usePercent,
|
||||
MountedOn: mountedOn,
|
||||
SizeMiB: parseSizeToMiB(size),
|
||||
UsedMiB: parseSizeToMiB(used),
|
||||
AvailMiB: parseSizeToMiB(avail),
|
||||
}
|
||||
dataRows = append(dataRows, row)
|
||||
}
|
||||
if len(dataRows) == 0 {
|
||||
return settings.DiskWidgetData{}, fmt.Errorf("no df data rows found")
|
||||
}
|
||||
|
||||
hint := strings.TrimSpace(mountHint)
|
||||
if hint != "" {
|
||||
for _, row := range dataRows {
|
||||
if row.MountedOn == hint {
|
||||
return row, nil
|
||||
}
|
||||
}
|
||||
// Also accept trailing-slash normalized match.
|
||||
hintTrim := strings.TrimSuffix(hint, "/")
|
||||
if hintTrim == "" {
|
||||
hintTrim = "/"
|
||||
}
|
||||
for _, row := range dataRows {
|
||||
mounted := strings.TrimSuffix(row.MountedOn, "/")
|
||||
if mounted == "" {
|
||||
mounted = "/"
|
||||
}
|
||||
if mounted == hintTrim {
|
||||
return row, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return dataRows[0], nil
|
||||
}
|
||||
|
||||
func parseSizeToMiB(raw string) int64 {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" || trimmed == "-" {
|
||||
return 0
|
||||
}
|
||||
matches := sizeUnitPattern.FindStringSubmatch(trimmed)
|
||||
if matches == nil {
|
||||
return 0
|
||||
}
|
||||
value, err := strconv.ParseFloat(matches[1], 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
unit := strings.ToUpper(matches[2])
|
||||
switch unit {
|
||||
case "", "B":
|
||||
return int64(value / (1024 * 1024))
|
||||
case "K", "KB", "KIB":
|
||||
return int64(value / 1024)
|
||||
case "M", "MB", "MIB":
|
||||
return int64(value)
|
||||
case "G", "GB", "GIB":
|
||||
return int64(value * 1024)
|
||||
case "T", "TB", "TIB":
|
||||
return int64(value * 1024 * 1024)
|
||||
case "P", "PB", "PIB":
|
||||
return int64(value * 1024 * 1024 * 1024)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package widgetparse
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||
)
|
||||
|
||||
func TestParseFreeM(t *testing.T) {
|
||||
stdout := ` total used free shared buff/cache available
|
||||
Mem: 16014 4521 2341 412 9151 11012
|
||||
Swap: 8191 0 8191
|
||||
`
|
||||
data, err := ParseFreeM(stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFreeM: %v", err)
|
||||
}
|
||||
if data.Total != 16014 || data.Used != 4521 || data.Free != 2341 {
|
||||
t.Fatalf("core fields = %+v", data)
|
||||
}
|
||||
if data.Shared != 412 || data.BuffCache != 9151 || data.Available != 11012 {
|
||||
t.Fatalf("extra fields = %+v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDfHPrefersMount(t *testing.T) {
|
||||
stdout := `Filesystem Size Used Avail Use% Mounted on
|
||||
/dev/sda1 100G 40G 55G 42% /
|
||||
/dev/sdb1 200G 10G 180G 6% /data
|
||||
`
|
||||
data, err := ParseDfH(stdout, "/data")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseDfH: %v", err)
|
||||
}
|
||||
if data.MountedOn != "/data" || data.Size != "200G" || data.Used != "10G" {
|
||||
t.Fatalf("row = %+v", data)
|
||||
}
|
||||
if data.SizeMiB != 200*1024 || data.UsedMiB != 10*1024 {
|
||||
t.Fatalf("mib = %+v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDfHFirstRow(t *testing.T) {
|
||||
stdout := `Filesystem Size Used Avail Use% Mounted on
|
||||
tmpfs 1.6G 0 1.6G 0% /run
|
||||
/dev/sda1 100G 40G 55G 42% /
|
||||
`
|
||||
data, err := ParseDfH(stdout, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseDfH: %v", err)
|
||||
}
|
||||
if data.MountedOn != "/run" {
|
||||
t.Fatalf("expected first row, got %+v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStdoutMemory(t *testing.T) {
|
||||
stdout := "Mem: 100 40 20 1 39 50\n"
|
||||
memory, disk, err := ParseStdout(settings.ActionWidgetTypeMemory, stdout, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseStdout: %v", err)
|
||||
}
|
||||
if memory == nil || disk != nil || memory.Total != 100 {
|
||||
t.Fatalf("memory=%v disk=%v", memory, disk)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MaxActionLogRuns is the retention cap per action-group log file.
|
||||
const MaxActionLogRuns = 200
|
||||
|
||||
// ActionLogDir returns action-logs/<nodeID> under the config dir.
|
||||
func ActionLogDir(configDir string, nodeID string) string {
|
||||
return filepath.Join(configDir, ActionLogsDirName, nodeID)
|
||||
}
|
||||
|
||||
// ActionLogFilePath returns the JSON log path for a node + group name.
|
||||
func ActionLogFilePath(configDir string, nodeID string, groupName string) string {
|
||||
filename := DiskSafeFilename(groupName) + ".json"
|
||||
return filepath.Join(ActionLogDir(configDir, nodeID), filename)
|
||||
}
|
||||
|
||||
// AppendActionLogRun appends a run record to the group's log file and trims retention.
|
||||
func AppendActionLogRun(configDir string, record ActionGroupRunRecord) error {
|
||||
if strings.TrimSpace(record.NodeID) == "" {
|
||||
return fmt.Errorf("node id is required")
|
||||
}
|
||||
if strings.TrimSpace(record.GroupName) == "" {
|
||||
return fmt.Errorf("group name is required")
|
||||
}
|
||||
|
||||
dir := ActionLogDir(configDir, record.NodeID)
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return fmt.Errorf("create action log dir: %w", err)
|
||||
}
|
||||
|
||||
path := ActionLogFilePath(configDir, record.NodeID, record.GroupName)
|
||||
file, err := LoadActionLogFileOrEmpty(path, record.NodeID, record.GroupID, record.GroupName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
file.NodeID = record.NodeID
|
||||
file.GroupID = record.GroupID
|
||||
file.GroupName = record.GroupName
|
||||
file.Runs = append(file.Runs, record)
|
||||
if len(file.Runs) > MaxActionLogRuns {
|
||||
file.Runs = file.Runs[len(file.Runs)-MaxActionLogRuns:]
|
||||
}
|
||||
|
||||
return SaveActionLogFile(path, file)
|
||||
}
|
||||
|
||||
// LoadActionLogFile reads a log JSON file from path.
|
||||
func LoadActionLogFile(path string) (ActionLogFile, error) {
|
||||
payload, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ActionLogFile{}, err
|
||||
}
|
||||
var file ActionLogFile
|
||||
if err := json.Unmarshal(payload, &file); err != nil {
|
||||
return ActionLogFile{}, fmt.Errorf("parse action log: %w", err)
|
||||
}
|
||||
if file.Runs == nil {
|
||||
file.Runs = []ActionGroupRunRecord{}
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
// LoadActionLogFileOrEmpty returns an empty file when missing.
|
||||
func LoadActionLogFileOrEmpty(
|
||||
path string,
|
||||
nodeID string,
|
||||
groupID string,
|
||||
groupName string,
|
||||
) (ActionLogFile, error) {
|
||||
file, err := LoadActionLogFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return ActionLogFile{
|
||||
NodeID: nodeID,
|
||||
GroupID: groupID,
|
||||
GroupName: groupName,
|
||||
Runs: []ActionGroupRunRecord{},
|
||||
}, nil
|
||||
}
|
||||
return ActionLogFile{}, err
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
// SaveActionLogFile writes a log JSON file with mode 0640.
|
||||
func SaveActionLogFile(path string, file ActionLogFile) error {
|
||||
if file.Runs == nil {
|
||||
file.Runs = []ActionGroupRunRecord{}
|
||||
}
|
||||
payload, err := json.MarshalIndent(file, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode action log: %w", err)
|
||||
}
|
||||
payload = append(payload, '\n')
|
||||
if err := os.WriteFile(path, payload, 0o640); err != nil {
|
||||
return fmt.Errorf("write action log: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListActionLogFiles lists JSON log files for a node.
|
||||
func ListActionLogFiles(configDir string, nodeID string) ([]ActionLogFileInfo, error) {
|
||||
dir := ActionLogDir(configDir, nodeID)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return []ActionLogFileInfo{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("list action logs: %w", err)
|
||||
}
|
||||
|
||||
result := make([]ActionLogFileInfo, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
if !strings.HasSuffix(name, ".json") {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
groupName := strings.TrimSuffix(name, ".json")
|
||||
groupID := ""
|
||||
file, loadErr := LoadActionLogFile(filepath.Join(dir, name))
|
||||
if loadErr == nil {
|
||||
if file.GroupName != "" {
|
||||
groupName = file.GroupName
|
||||
}
|
||||
groupID = file.GroupID
|
||||
}
|
||||
result = append(result, ActionLogFileInfo{
|
||||
Filename: name,
|
||||
GroupName: groupName,
|
||||
GroupID: groupID,
|
||||
ModTime: info.ModTime().UTC(),
|
||||
SizeBytes: info.Size(),
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ReadActionLogFileByName reads one log file for a node after validating the name.
|
||||
func ReadActionLogFileByName(configDir string, nodeID string, filename string) (ActionLogFile, error) {
|
||||
safeName := filepath.Base(strings.TrimSpace(filename))
|
||||
if safeName == "." || safeName == ".." || safeName == "" {
|
||||
return ActionLogFile{}, fmt.Errorf("invalid log filename")
|
||||
}
|
||||
if !strings.HasSuffix(safeName, ".json") {
|
||||
return ActionLogFile{}, fmt.Errorf("invalid log filename")
|
||||
}
|
||||
if strings.Contains(safeName, "/") || strings.Contains(safeName, `\`) {
|
||||
return ActionLogFile{}, fmt.Errorf("invalid log filename")
|
||||
}
|
||||
|
||||
path := filepath.Join(ActionLogDir(configDir, nodeID), safeName)
|
||||
cleaned := filepath.Clean(path)
|
||||
if !strings.HasPrefix(cleaned, filepath.Clean(ActionLogDir(configDir, nodeID))+string(os.PathSeparator)) {
|
||||
return ActionLogFile{}, fmt.Errorf("invalid log filename")
|
||||
}
|
||||
return LoadActionLogFile(cleaned)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LoadActionWidgets reads and parses node-action-widgets.json from dir.
|
||||
func LoadActionWidgets(dir string) (ActionWidgetStore, error) {
|
||||
path := filepath.Join(dir, NodeActionWidgetsFileName)
|
||||
payload, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ActionWidgetStore{}, err
|
||||
}
|
||||
|
||||
var store ActionWidgetStore
|
||||
if err := json.Unmarshal(payload, &store); err != nil {
|
||||
return ActionWidgetStore{}, fmt.Errorf("parse action widgets: %w", err)
|
||||
}
|
||||
if store.Widgets == nil {
|
||||
store.Widgets = []ActionWidgetSnapshot{}
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// LoadActionWidgetsOrEmpty returns an empty store when the file is missing.
|
||||
func LoadActionWidgetsOrEmpty(dir string) (ActionWidgetStore, error) {
|
||||
store, err := LoadActionWidgets(dir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return ActionWidgetStore{Widgets: []ActionWidgetSnapshot{}}, nil
|
||||
}
|
||||
return ActionWidgetStore{}, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// SaveActionWidgets writes node-action-widgets.json to dir with mode 0640.
|
||||
func SaveActionWidgets(dir string, store ActionWidgetStore) error {
|
||||
if err := ensureConfigDir(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
if store.Widgets == nil {
|
||||
store.Widgets = []ActionWidgetSnapshot{}
|
||||
}
|
||||
|
||||
payload, err := json.MarshalIndent(store, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode action widgets: %w", err)
|
||||
}
|
||||
payload = append(payload, '\n')
|
||||
|
||||
path := filepath.Join(dir, NodeActionWidgetsFileName)
|
||||
return os.WriteFile(path, payload, 0o640)
|
||||
}
|
||||
|
||||
// WidgetsForNode returns snapshots belonging to nodeID.
|
||||
func WidgetsForNode(store ActionWidgetStore, nodeID string) []ActionWidgetSnapshot {
|
||||
out := make([]ActionWidgetSnapshot, 0)
|
||||
for _, widget := range store.Widgets {
|
||||
if widget.NodeID == nodeID {
|
||||
out = append(out, widget)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// UpsertActionWidgetSnapshot replaces or appends a snapshot keyed by node_id + item_id.
|
||||
func UpsertActionWidgetSnapshot(dir string, snapshot ActionWidgetSnapshot) error {
|
||||
if strings.TrimSpace(snapshot.NodeID) == "" {
|
||||
return fmt.Errorf("node id is required")
|
||||
}
|
||||
if strings.TrimSpace(snapshot.ItemID) == "" {
|
||||
return fmt.Errorf("item id is required")
|
||||
}
|
||||
if snapshot.UpdatedAt.IsZero() {
|
||||
snapshot.UpdatedAt = time.Now().UTC()
|
||||
}
|
||||
|
||||
store, err := LoadActionWidgetsOrEmpty(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
found := false
|
||||
for index := range store.Widgets {
|
||||
if store.Widgets[index].NodeID == snapshot.NodeID &&
|
||||
store.Widgets[index].ItemID == snapshot.ItemID {
|
||||
store.Widgets[index] = snapshot
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
store.Widgets = append(store.Widgets, snapshot)
|
||||
}
|
||||
return SaveActionWidgets(dir, store)
|
||||
}
|
||||
|
||||
// DeleteActionWidgetsForItem removes the snapshot for a node action item, if any.
|
||||
func DeleteActionWidgetsForItem(dir string, nodeID string, itemID string) error {
|
||||
store, err := LoadActionWidgetsOrEmpty(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filtered := make([]ActionWidgetSnapshot, 0, len(store.Widgets))
|
||||
changed := false
|
||||
for _, widget := range store.Widgets {
|
||||
if widget.NodeID == nodeID && widget.ItemID == itemID {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, widget)
|
||||
}
|
||||
if !changed {
|
||||
return nil
|
||||
}
|
||||
store.Widgets = filtered
|
||||
return SaveActionWidgets(dir, store)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestEnsureBuiltinActionsAddsMemoryAndUpdatesDisk(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
store := ActionStore{
|
||||
Actions: []Action{
|
||||
{
|
||||
ID: BuiltinActionUptimeID,
|
||||
Name: "Uptime",
|
||||
Kind: ActionKindShell,
|
||||
Body: "uptime",
|
||||
Env: []ActionEnvVar{},
|
||||
Builtin: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
{
|
||||
ID: BuiltinActionDiskUsageID,
|
||||
Name: "Disk usage",
|
||||
Kind: ActionKindShell,
|
||||
Body: "df -h",
|
||||
Env: []ActionEnvVar{},
|
||||
Builtin: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
{
|
||||
ID: BuiltinActionSysInfoID,
|
||||
Name: "System info",
|
||||
Kind: ActionKindScript,
|
||||
Body: "uname -a",
|
||||
Env: []ActionEnvVar{},
|
||||
Builtin: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
{
|
||||
ID: "custom-1",
|
||||
Name: "Custom",
|
||||
Kind: ActionKindShell,
|
||||
Body: "echo hi",
|
||||
Env: []ActionEnvVar{},
|
||||
Builtin: false,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
updated, changed := EnsureBuiltinActions(store)
|
||||
if !changed {
|
||||
t.Fatal("expected changes")
|
||||
}
|
||||
|
||||
foundMemory := false
|
||||
foundCustom := false
|
||||
for _, action := range updated.Actions {
|
||||
if action.ID == BuiltinActionMemoryID {
|
||||
foundMemory = true
|
||||
if !action.ShowWidget || action.WidgetType != ActionWidgetTypeMemory {
|
||||
t.Fatalf("memory widget = %+v", action)
|
||||
}
|
||||
if action.Body != "free -m" {
|
||||
t.Fatalf("memory body = %q", action.Body)
|
||||
}
|
||||
}
|
||||
if action.ID == BuiltinActionDiskUsageID {
|
||||
if action.Body != "df -h {{env.MOUNT}}" {
|
||||
t.Fatalf("disk body = %q", action.Body)
|
||||
}
|
||||
if len(action.Env) != 1 || action.Env[0].Name != "MOUNT" || action.Env[0].Value != "/" {
|
||||
t.Fatalf("disk env = %+v", action.Env)
|
||||
}
|
||||
if !action.ShowWidget || action.WidgetType != ActionWidgetTypeDisk {
|
||||
t.Fatalf("disk widget = %+v", action)
|
||||
}
|
||||
}
|
||||
if action.ID == "custom-1" {
|
||||
foundCustom = true
|
||||
}
|
||||
}
|
||||
if !foundMemory {
|
||||
t.Fatal("memory builtin missing")
|
||||
}
|
||||
if !foundCustom {
|
||||
t.Fatal("custom action was dropped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertActionWidgetSnapshot(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
first := ActionWidgetSnapshot{
|
||||
NodeID: "node-1",
|
||||
ItemID: "item-1",
|
||||
GroupID: "group-1",
|
||||
WidgetType: ActionWidgetTypeMemory,
|
||||
Title: "Memory",
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
ExitCode: 0,
|
||||
RawStdout: "Mem: 1 1 0 0 0 0",
|
||||
Memory: &MemoryWidgetData{Total: 1, Used: 1},
|
||||
}
|
||||
if err := UpsertActionWidgetSnapshot(dir, first); err != nil {
|
||||
t.Fatalf("upsert: %v", err)
|
||||
}
|
||||
second := first
|
||||
second.Title = "RAM"
|
||||
second.Memory = &MemoryWidgetData{Total: 2, Used: 1, Free: 1}
|
||||
if err := UpsertActionWidgetSnapshot(dir, second); err != nil {
|
||||
t.Fatalf("upsert2: %v", err)
|
||||
}
|
||||
|
||||
store, err := LoadActionWidgets(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if len(store.Widgets) != 1 {
|
||||
t.Fatalf("len = %d", len(store.Widgets))
|
||||
}
|
||||
if store.Widgets[0].Title != "RAM" || store.Widgets[0].Memory.Total != 2 {
|
||||
t.Fatalf("widget = %+v", store.Widgets[0])
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, NodeActionWidgetsFileName)
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("missing file: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
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"
|
||||
BuiltinActionMemoryID = "00000000-0000-4000-8000-000000000004"
|
||||
)
|
||||
|
||||
// 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{},
|
||||
ShowWidget: true,
|
||||
WidgetType: ActionWidgetTypeRaw,
|
||||
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.MOUNT}}",
|
||||
Env: []ActionEnvVar{
|
||||
{Name: "MOUNT", Value: "/"},
|
||||
},
|
||||
ShowWidget: true,
|
||||
WidgetType: ActionWidgetTypeDisk,
|
||||
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{},
|
||||
ShowWidget: true,
|
||||
WidgetType: ActionWidgetTypeRaw,
|
||||
Builtin: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
{
|
||||
ID: BuiltinActionMemoryID,
|
||||
Name: "Memory",
|
||||
Description: "Show memory usage on the target (free -m).",
|
||||
Kind: ActionKindShell,
|
||||
Body: "free -m",
|
||||
Env: []ActionEnvVar{},
|
||||
ShowWidget: true,
|
||||
WidgetType: ActionWidgetTypeMemory,
|
||||
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.
|
||||
// Existing installs are updated via EnsureBuiltinActions.
|
||||
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
|
||||
}
|
||||
updated, changed := EnsureBuiltinActions(store)
|
||||
if changed {
|
||||
if saveErr := SaveActions(dir, updated); saveErr != nil {
|
||||
return ActionStore{}, saveErr
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// EnsureBuiltinActions merges expected builtin definitions into the store.
|
||||
// Custom (non-builtin) actions are preserved. Builtin widget settings already
|
||||
// customized by the user are kept when the action already exists with matching ID;
|
||||
// body/env/name for known builtins are refreshed to the current seed values.
|
||||
// Returns the updated store and whether anything changed.
|
||||
func EnsureBuiltinActions(store ActionStore) (ActionStore, bool) {
|
||||
if store.Actions == nil {
|
||||
store.Actions = []Action{}
|
||||
}
|
||||
defaults := DefaultBuiltinActions()
|
||||
byID := make(map[string]int, len(store.Actions))
|
||||
for index := range store.Actions {
|
||||
byID[store.Actions[index].ID] = index
|
||||
}
|
||||
|
||||
changed := false
|
||||
now := time.Now().UTC()
|
||||
for _, desired := range defaults {
|
||||
index, exists := byID[desired.ID]
|
||||
if !exists {
|
||||
desired.CreatedAt = now
|
||||
desired.UpdatedAt = now
|
||||
store.Actions = append(store.Actions, desired)
|
||||
byID[desired.ID] = len(store.Actions) - 1
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
current := store.Actions[index]
|
||||
// Preserve user-chosen widget toggle/type on existing builtins.
|
||||
showWidget := current.ShowWidget
|
||||
widgetType := current.WidgetType
|
||||
if !current.Builtin {
|
||||
// ID collision with a custom action: leave it alone.
|
||||
continue
|
||||
}
|
||||
needsUpdate := current.Name != desired.Name ||
|
||||
current.Description != desired.Description ||
|
||||
current.Kind != desired.Kind ||
|
||||
current.Body != desired.Body ||
|
||||
!envVarsEqual(current.Env, desired.Env) ||
|
||||
current.RequiresSudo != desired.RequiresSudo
|
||||
if !needsUpdate {
|
||||
// Still ensure widget defaults if never set and seed wants widgets.
|
||||
if !current.ShowWidget && desired.ShowWidget && current.WidgetType == "" {
|
||||
current.ShowWidget = desired.ShowWidget
|
||||
current.WidgetType = desired.WidgetType
|
||||
current.UpdatedAt = now
|
||||
store.Actions[index] = current
|
||||
changed = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
current.Name = desired.Name
|
||||
current.Description = desired.Description
|
||||
current.Kind = desired.Kind
|
||||
current.Body = desired.Body
|
||||
current.Env = append([]ActionEnvVar(nil), desired.Env...)
|
||||
current.RequiresSudo = desired.RequiresSudo
|
||||
current.Builtin = true
|
||||
// Keep existing widget prefs; only fill defaults when unset.
|
||||
if current.WidgetType == "" && desired.WidgetType != "" {
|
||||
current.ShowWidget = desired.ShowWidget
|
||||
current.WidgetType = desired.WidgetType
|
||||
} else {
|
||||
current.ShowWidget = showWidget
|
||||
current.WidgetType = widgetType
|
||||
}
|
||||
current.UpdatedAt = now
|
||||
store.Actions[index] = current
|
||||
changed = true
|
||||
}
|
||||
return store, changed
|
||||
}
|
||||
|
||||
func envVarsEqual(left, right []ActionEnvVar) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for index := range left {
|
||||
if left[index].Name != right[index].Name || left[index].Value != right[index].Value {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 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)
|
||||
return os.WriteFile(path, payload, 0o640)
|
||||
}
|
||||
@@ -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) != 4 {
|
||||
t.Fatalf("expected 4 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) != 4 {
|
||||
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) != 5 {
|
||||
t.Fatalf("loaded len = %d", len(loaded.Actions))
|
||||
}
|
||||
custom := loaded.Actions[4]
|
||||
if custom.Name != "Custom" || len(custom.Env) != 1 || custom.Env[0].Name != "APP_HOME" {
|
||||
t.Fatalf("custom = %#v", custom)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// MaxAuthAuditEvents is the retention cap for auth-audit.json.
|
||||
const MaxAuthAuditEvents = 1000
|
||||
|
||||
// LoadAuthAudit reads and parses auth-audit.json from dir.
|
||||
func LoadAuthAudit(dir string) (AuthAuditStore, error) {
|
||||
path := filepath.Join(dir, AuthAuditFileName)
|
||||
payload, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return AuthAuditStore{}, err
|
||||
}
|
||||
|
||||
var store AuthAuditStore
|
||||
if err := json.Unmarshal(payload, &store); err != nil {
|
||||
return AuthAuditStore{}, fmt.Errorf("parse auth audit: %w", err)
|
||||
}
|
||||
if store.Events == nil {
|
||||
store.Events = []AuthAuditEvent{}
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// LoadAuthAuditOrEmpty returns an empty store when auth-audit.json is missing.
|
||||
func LoadAuthAuditOrEmpty(dir string) (AuthAuditStore, error) {
|
||||
store, err := LoadAuthAudit(dir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return AuthAuditStore{Events: []AuthAuditEvent{}}, nil
|
||||
}
|
||||
return AuthAuditStore{}, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// SaveAuthAudit writes auth-audit.json to dir with mode 0640.
|
||||
func SaveAuthAudit(dir string, store AuthAuditStore) error {
|
||||
if err := ensureConfigDir(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
if store.Events == nil {
|
||||
store.Events = []AuthAuditEvent{}
|
||||
}
|
||||
|
||||
payload, err := json.MarshalIndent(store, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode auth audit: %w", err)
|
||||
}
|
||||
payload = append(payload, '\n')
|
||||
|
||||
path := filepath.Join(dir, AuthAuditFileName)
|
||||
if err := os.WriteFile(path, payload, 0o640); err != nil {
|
||||
return fmt.Errorf("write auth audit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AppendAuthAuditEvent appends event and trims to MaxAuthAuditEvents (newest kept).
|
||||
func AppendAuthAuditEvent(dir string, event AuthAuditEvent) error {
|
||||
store, err := LoadAuthAuditOrEmpty(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
store.Events = append(store.Events, event)
|
||||
if len(store.Events) > MaxAuthAuditEvents {
|
||||
store.Events = store.Events[len(store.Events)-MaxAuthAuditEvents:]
|
||||
}
|
||||
return SaveAuthAudit(dir, store)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAuthAuditRoundTripAndCap(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
store, err := LoadAuthAuditOrEmpty(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAuthAuditOrEmpty: %v", err)
|
||||
}
|
||||
if len(store.Events) != 0 {
|
||||
t.Fatalf("expected empty, got %d", len(store.Events))
|
||||
}
|
||||
|
||||
event := AuthAuditEvent{
|
||||
ID: "evt-1",
|
||||
At: time.Now().UTC(),
|
||||
Action: "login",
|
||||
Outcome: AuthAuditOutcomeSuccess,
|
||||
Category: AuthAuditCategoryAuth,
|
||||
Actor: "admin",
|
||||
}
|
||||
if err := AppendAuthAuditEvent(dir, event); err != nil {
|
||||
t.Fatalf("AppendAuthAuditEvent: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadAuthAudit(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAuthAudit: %v", err)
|
||||
}
|
||||
if len(loaded.Events) != 1 || loaded.Events[0].Action != "login" {
|
||||
t.Fatalf("loaded = %+v", loaded)
|
||||
}
|
||||
if loaded.Events[0].Outcome != AuthAuditOutcomeSuccess {
|
||||
t.Fatalf("outcome = %q", loaded.Events[0].Outcome)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendAuthAuditEventTrimsToCap(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
originalCap := MaxAuthAuditEvents
|
||||
store := AuthAuditStore{Events: make([]AuthAuditEvent, 0, MaxAuthAuditEvents+5)}
|
||||
for index := 0; index < MaxAuthAuditEvents+5; index++ {
|
||||
store.Events = append(store.Events, AuthAuditEvent{
|
||||
ID: "evt",
|
||||
At: time.Now().UTC(),
|
||||
Action: "login",
|
||||
Outcome: AuthAuditOutcomeSuccess,
|
||||
Category: AuthAuditCategoryAuth,
|
||||
Actor: "admin",
|
||||
Detail: string(rune('a' + (index % 26))),
|
||||
})
|
||||
}
|
||||
if err := SaveAuthAudit(dir, store); err != nil {
|
||||
t.Fatalf("SaveAuthAudit: %v", err)
|
||||
}
|
||||
if err := AppendAuthAuditEvent(dir, AuthAuditEvent{
|
||||
ID: "newest",
|
||||
At: time.Now().UTC(),
|
||||
Action: "logout",
|
||||
Outcome: AuthAuditOutcomeSuccess,
|
||||
Category: AuthAuditCategoryAuth,
|
||||
Actor: "admin",
|
||||
}); err != nil {
|
||||
t.Fatalf("AppendAuthAuditEvent: %v", err)
|
||||
}
|
||||
loaded, err := LoadAuthAudit(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAuthAudit: %v", err)
|
||||
}
|
||||
if len(loaded.Events) != originalCap {
|
||||
t.Fatalf("len = %d, want %d", len(loaded.Events), originalCap)
|
||||
}
|
||||
if loaded.Events[len(loaded.Events)-1].ID != "newest" {
|
||||
t.Fatalf("last id = %q", loaded.Events[len(loaded.Events)-1].ID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
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"
|
||||
NodeActionWidgetsFileName = "node-action-widgets.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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// ActionWidgetType identifies how action stdout is parsed for Overview widgets.
|
||||
type ActionWidgetType string
|
||||
|
||||
const (
|
||||
ActionWidgetTypeNone ActionWidgetType = ""
|
||||
ActionWidgetTypeMemory ActionWidgetType = "memory"
|
||||
ActionWidgetTypeDisk ActionWidgetType = "disk"
|
||||
ActionWidgetTypeRaw ActionWidgetType = "raw"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
ShowWidget bool `json:"show_widget"`
|
||||
WidgetType ActionWidgetType `json:"widget_type,omitempty"`
|
||||
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"
|
||||
WidgetLabel string `json:"widget_label,omitempty"` // Overview widget title override
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// MemoryWidgetData holds parsed free -m memory figures (MiB).
|
||||
type MemoryWidgetData struct {
|
||||
Total int64 `json:"total"`
|
||||
Used int64 `json:"used"`
|
||||
Free int64 `json:"free"`
|
||||
Shared int64 `json:"shared"`
|
||||
BuffCache int64 `json:"buff_cache"`
|
||||
Available int64 `json:"available"`
|
||||
}
|
||||
|
||||
// DiskWidgetData holds parsed df -h figures for one filesystem row.
|
||||
type DiskWidgetData struct {
|
||||
Filesystem string `json:"filesystem"`
|
||||
Size string `json:"size"`
|
||||
Used string `json:"used"`
|
||||
Avail string `json:"avail"`
|
||||
UsePercent string `json:"use_percent"`
|
||||
MountedOn string `json:"mounted_on"`
|
||||
// Numeric MiB estimates for chart proportions (0 when unknown).
|
||||
SizeMiB int64 `json:"size_mib,omitempty"`
|
||||
UsedMiB int64 `json:"used_mib,omitempty"`
|
||||
AvailMiB int64 `json:"avail_mib,omitempty"`
|
||||
}
|
||||
|
||||
// ActionWidgetSnapshot is the last widget payload for one action-group item on a node.
|
||||
type ActionWidgetSnapshot struct {
|
||||
NodeID string `json:"node_id"`
|
||||
ItemID string `json:"item_id"`
|
||||
GroupID string `json:"group_id"`
|
||||
LibraryActionID string `json:"library_action_id"`
|
||||
WidgetType ActionWidgetType `json:"widget_type"`
|
||||
Title string `json:"title"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
Error string `json:"error,omitempty"`
|
||||
RawStdout string `json:"raw_stdout"`
|
||||
Memory *MemoryWidgetData `json:"memory,omitempty"`
|
||||
Disk *DiskWidgetData `json:"disk,omitempty"`
|
||||
}
|
||||
|
||||
// ActionWidgetStore is the plain JSON payload in node-action-widgets.json.
|
||||
type ActionWidgetStore struct {
|
||||
Widgets []ActionWidgetSnapshot `json:"widgets"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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?
|
||||
@@ -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 }]
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
Generated
+2696
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user