Compare commits
12
Commits
main
..
77be394407
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,128 @@
|
||||
# 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-based dashboard for managing Proxmox VMs, LXC containers, and Docker. After configuration it discovers hosts and shows per‑instance details like hostname, storage and RAM usage, and status. From the same interface you can view logs, run updates, deploy changes, and perform other routine maintenance tasks.
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | Role |
|
||||
|------|------|
|
||||
| `service/` | Go HTTP API |
|
||||
| `webui/` | Vite + React + TypeScript SPA |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Go 1.22+ (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` | Plain base settings (non-secret), including setup/network/security/groups |
|
||||
| `passwords.enc` | AES-GCM encrypted user credentials (argon2id hashes, TOTP secrets) |
|
||||
| `sessions.enc` | AES-GCM encrypted session store |
|
||||
| `secrets.enc` | AES-GCM encrypted JSON secrets (integrations, etc.) |
|
||||
|
||||
**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` 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,63 @@
|
||||
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
|
||||
server := &http.Server{
|
||||
Addr: listenAddr,
|
||||
Handler: api.NewRouter(configDir),
|
||||
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
|
||||
|
||||
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,114 @@
|
||||
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 errNodesDeleteRequired = errors.New("permission nodes.delete 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 (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) 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) 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
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
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": {},
|
||||
"users.manage": {},
|
||||
"secrets.manage": {},
|
||||
"roles.manage": {},
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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,323 @@
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||
)
|
||||
|
||||
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)
|
||||
session, err := app.Sessions.LookupValidSession(writer, request, security)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "authentication required"})
|
||||
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")
|
||||
|
||||
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,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,671 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"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",
|
||||
})
|
||||
}
|
||||
|
||||
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.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.LoadNodeAuditOrEmpty(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
nodesStore, err := settings.LoadNodesOrEmpty(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
nodeByID := make(map[string]settings.Node, len(nodesStore.Nodes))
|
||||
for _, node := range nodesStore.Nodes {
|
||||
nodeByID[node.ID] = node
|
||||
}
|
||||
|
||||
kindFilter := strings.TrimSpace(request.URL.Query().Get("kind"))
|
||||
isAdmin := userIsAdministrator(user)
|
||||
filtered := make([]settings.NodeAuditEvent, 0, len(store.Events))
|
||||
for index := len(store.Events) - 1; index >= 0; index-- {
|
||||
event := store.Events[index]
|
||||
if kindFilter != "" && string(event.NodeKind) != kindFilter {
|
||||
continue
|
||||
}
|
||||
if !userCanSeeNodeAuditEvent(user, settingsPayload.Groups, nodeByID, event, isAdmin) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, event)
|
||||
}
|
||||
|
||||
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 userCanSeeNodeAuditEvent(
|
||||
user settings.UserCredential,
|
||||
groups []settings.Group,
|
||||
nodeByID map[string]settings.Node,
|
||||
event settings.NodeAuditEvent,
|
||||
isAdmin bool,
|
||||
) bool {
|
||||
if isAdmin {
|
||||
return true
|
||||
}
|
||||
if event.Actor == user.Username {
|
||||
return true
|
||||
}
|
||||
if node, ok := nodeByID[event.NodeID]; ok {
|
||||
return userCanAccessNode(user, groups, node)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func appendNodeAudit(
|
||||
configDir string,
|
||||
action settings.NodeAuditAction,
|
||||
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,646 @@
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,57 @@
|
||||
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 {
|
||||
app, err := newApp(configDir)
|
||||
if err != nil {
|
||||
// newApp currently always succeeds; keep signature for future.
|
||||
panic(err)
|
||||
}
|
||||
|
||||
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/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("DELETE /api/v1/nodes/{id}", app.nodesDeleteHandler)
|
||||
mux.HandleFunc("POST /api/v1/nodes/{id}/test-ssh", app.nodesTestSSHHandler)
|
||||
mux.HandleFunc("GET /api/v1/node-logs", app.nodeLogsListHandler)
|
||||
|
||||
return app.withCORS(app.withMiddleware(mux))
|
||||
}
|
||||
@@ -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,485 @@
|
||||
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/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
|
||||
|
||||
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.
|
||||
return &App{
|
||||
ConfigDir: configDir,
|
||||
pendingTOTP: map[string]string{},
|
||||
}, nil
|
||||
}
|
||||
return &App{
|
||||
ConfigDir: configDir,
|
||||
Key: key,
|
||||
Sessions: auth.NewSessionManager(configDir, key),
|
||||
pendingTOTP: map[string]string{},
|
||||
}, 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
|
||||
}
|
||||
}
|
||||
if matched == nil || !matched.Enabled {
|
||||
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid credentials"})
|
||||
return
|
||||
}
|
||||
|
||||
ok, err := auth.VerifyPassword(payload.Password, matched.PasswordHash)
|
||||
if err != nil || !ok {
|
||||
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) {
|
||||
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
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, app.buildMeResponse(*matched, settingsPayload))
|
||||
}
|
||||
|
||||
func (app *App) logoutHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
if app.Sessions != nil {
|
||||
_ = app.Sessions.DestroySession(writer, request)
|
||||
}
|
||||
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",
|
||||
"users.manage",
|
||||
"secrets.manage",
|
||||
"roles.manage",
|
||||
}
|
||||
_ = 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)
|
||||
return payload, nil
|
||||
}
|
||||
@@ -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,396 @@
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
for _, user := range store.Users {
|
||||
if user.ID != userID {
|
||||
remaining = append(remaining, user)
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
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)
|
||||
}
|
||||
|
||||
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,278 @@
|
||||
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")
|
||||
|
||||
// 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 and may rotate it.
|
||||
func (manager *SessionManager) LookupValidSession(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
security settings.SecuritySettings,
|
||||
) (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
|
||||
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 {
|
||||
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)
|
||||
return settings.SessionRecord{}, ErrSessionNotFound
|
||||
}
|
||||
|
||||
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,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,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,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,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,69 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// 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,188 @@
|
||||
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"`
|
||||
}
|
||||
|
||||
// NodeStore is the plain JSON payload in nodes.json.
|
||||
type NodeStore struct {
|
||||
Nodes []Node `json:"nodes"`
|
||||
}
|
||||
|
||||
// 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"
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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,32 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the Oxlint configuration
|
||||
|
||||
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"options": {
|
||||
"typeAware": true
|
||||
},
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,946 @@
|
||||
.app-shell {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
color: var(--ctp-text);
|
||||
font-family: 'IBM Plex Sans', 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
background: var(--ctp-mantle);
|
||||
border-right: 1px solid var(--ctp-surface0);
|
||||
transition:
|
||||
background-color 160ms ease,
|
||||
border-color 160ms ease;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 1.25rem 1.25rem 1rem;
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
}
|
||||
|
||||
.brand {
|
||||
margin: 0 0 0.65rem;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.2;
|
||||
color: var(--ctp-mauve);
|
||||
}
|
||||
|
||||
.version-line {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.version-line + .version-line {
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
gap: 0.25rem;
|
||||
padding: 0.75rem 0.75rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.nav-section-divider {
|
||||
width: 40%;
|
||||
height: 1px;
|
||||
margin-block: 0.4rem;
|
||||
margin-inline: auto;
|
||||
background: var(--ctp-surface1);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.sidebar-logs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem 0.75rem 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-category-label {
|
||||
margin: 0 0 0.15rem;
|
||||
padding: 0 0.75rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.75rem;
|
||||
border-top: 1px solid var(--ctp-surface0);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
appearance: none;
|
||||
border: none;
|
||||
border-radius: 0.35rem;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 0.6rem 0.75rem;
|
||||
font: inherit;
|
||||
font-size: 0.95rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: var(--ctp-mauve-hover);
|
||||
}
|
||||
|
||||
.nav-item:focus-visible {
|
||||
outline: 2px solid var(--ctp-mauve);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.nav-item-active {
|
||||
background: var(--ctp-mauve-muted);
|
||||
color: var(--ctp-mauve);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.config-item,
|
||||
.profile-item {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cog-icon,
|
||||
.user-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.profile-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
max-width: 40rem;
|
||||
}
|
||||
|
||||
.profile-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.profile-section-title {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.profile-form,
|
||||
.users-create-form,
|
||||
.reauth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
max-width: 24rem;
|
||||
}
|
||||
|
||||
.profile-form label,
|
||||
.users-create-form label,
|
||||
.reauth-form label,
|
||||
.users-inline-edit label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.profile-form input,
|
||||
.users-create-form input,
|
||||
.reauth-form input,
|
||||
.users-inline-edit input {
|
||||
padding: 0.45rem 0.6rem;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 6px;
|
||||
background: var(--ctp-mantle);
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.totp-qr {
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.users-group-fieldset {
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.users-row-actions,
|
||||
.users-inline-actions,
|
||||
.reauth-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.users-inline-edit {
|
||||
margin-top: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.reauth-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: color-mix(in srgb, var(--ctp-crust) 70%, transparent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 40;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.reauth-modal {
|
||||
background: var(--ctp-base);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 10px;
|
||||
padding: 1.25rem 1.5rem;
|
||||
width: min(24rem, 100%);
|
||||
box-shadow: 0 12px 40px color-mix(in srgb, var(--ctp-crust) 45%, transparent);
|
||||
}
|
||||
|
||||
.reauth-modal h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.15rem;
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 2rem 2.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.content h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.75rem;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.content p {
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
color: var(--ctp-subtext1);
|
||||
}
|
||||
|
||||
.config-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.config-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
gap: 0.15rem 0.25rem;
|
||||
border-bottom: 1px solid var(--ctp-surface1);
|
||||
}
|
||||
|
||||
.config-tab {
|
||||
appearance: none;
|
||||
margin: 0;
|
||||
padding: 0.55rem 0.85rem;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
background: transparent;
|
||||
color: var(--ctp-subtext1);
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.config-tab:hover {
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.config-tab-active {
|
||||
color: var(--ctp-mauve);
|
||||
border-bottom-color: var(--ctp-mauve);
|
||||
}
|
||||
|
||||
.config-tab:focus-visible {
|
||||
outline: 2px solid var(--ctp-mauve);
|
||||
outline-offset: 2px;
|
||||
border-radius: 0.2rem;
|
||||
}
|
||||
|
||||
.config-tab-panel {
|
||||
min-width: 0;
|
||||
max-width: 40rem;
|
||||
}
|
||||
|
||||
.config-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
max-width: 28rem;
|
||||
}
|
||||
|
||||
.config-field label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.theme-select {
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
max-width: 16rem;
|
||||
padding: 0.55rem 2rem 0.55rem 0.75rem;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.35rem;
|
||||
background-color: var(--ctp-mantle);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M2.5 4.5L6 8L9.5 4.5' stroke='%237c7f93' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.75rem center;
|
||||
color: var(--ctp-text);
|
||||
font: inherit;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.theme-select:focus-visible {
|
||||
outline: 2px solid var(--ctp-mauve);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.config-hint {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.45;
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.config-empty {
|
||||
margin: 0;
|
||||
max-width: 36rem;
|
||||
line-height: 1.5;
|
||||
color: var(--ctp-subtext1);
|
||||
}
|
||||
|
||||
.config-error {
|
||||
margin: 0;
|
||||
color: var(--ctp-red);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.node-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.node-list-item {
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
}
|
||||
|
||||
.node-list-heading {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem 1rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.node-id {
|
||||
font-size: 0.8rem;
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.node-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.node-test-ssh {
|
||||
padding: 0.35rem 0.7rem;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 6px;
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--ctp-text);
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.node-test-ssh:hover:not(:disabled) {
|
||||
border-color: var(--ctp-overlay0);
|
||||
}
|
||||
|
||||
.node-test-ssh:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.node-delete {
|
||||
padding: 0.35rem 0.7rem;
|
||||
border: 1px solid var(--ctp-red);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--ctp-red);
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.node-delete:hover {
|
||||
background: color-mix(in srgb, var(--ctp-red) 12%, transparent);
|
||||
}
|
||||
|
||||
.node-test-result {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
max-width: 40rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.node-test-result-ok {
|
||||
color: var(--ctp-green);
|
||||
}
|
||||
|
||||
.node-test-result-fail {
|
||||
color: var(--ctp-red);
|
||||
}
|
||||
|
||||
.resource-add-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
max-width: 36rem;
|
||||
}
|
||||
|
||||
.resource-add-form .field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.resource-add-form input,
|
||||
.resource-add-form select,
|
||||
.resource-add-form textarea {
|
||||
padding: 0.45rem 0.6rem;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 6px;
|
||||
background: var(--ctp-mantle);
|
||||
color: var(--ctp-text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.resource-add-form input[readonly],
|
||||
.resource-add-form textarea[readonly],
|
||||
.resource-add-form .readonly-field {
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--ctp-subtext0);
|
||||
border-color: var(--ctp-surface0);
|
||||
cursor: default;
|
||||
opacity: 1;
|
||||
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas,
|
||||
monospace;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.resource-add-form .readonly-field:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.field-group {
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--ctp-surface0);
|
||||
border-radius: 0.35rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.field-group legend {
|
||||
padding: 0 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.radio-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem 1.25rem;
|
||||
}
|
||||
|
||||
.radio-row label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.created-public-key {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.roles-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.permission-catalog {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.permission-catalog code {
|
||||
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas,
|
||||
monospace;
|
||||
font-size: 0.9rem;
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.config-input {
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.35rem;
|
||||
background: var(--ctp-mantle);
|
||||
color: var(--ctp-text);
|
||||
font: inherit;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.config-input:focus-visible {
|
||||
outline: 2px solid var(--ctp-mauve);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.groups-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
max-width: 40rem;
|
||||
}
|
||||
|
||||
.security-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
max-width: 40rem;
|
||||
}
|
||||
|
||||
.groups-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.groups-ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.groups-li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.35rem;
|
||||
background: var(--ctp-surface0);
|
||||
}
|
||||
|
||||
.groups-li-selected {
|
||||
border-color: var(--ctp-mauve);
|
||||
background: var(--ctp-mantle);
|
||||
}
|
||||
|
||||
.groups-li-actions {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.groups-li-title {
|
||||
font-weight: 650;
|
||||
color: var(--ctp-text);
|
||||
margin-bottom: 0.1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.user-badge {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-subtext0);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
}
|
||||
|
||||
.groups-li-meta {
|
||||
font-size: 0.85rem;
|
||||
color: var(--ctp-subtext0);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.groups-edit,
|
||||
.groups-delete,
|
||||
.groups-new {
|
||||
appearance: none;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
background: transparent;
|
||||
color: var(--ctp-text);
|
||||
border-radius: 0.35rem;
|
||||
padding: 0.3rem 0.7rem;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.groups-delete:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.groups-delete:disabled:hover {
|
||||
background: transparent;
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.groups-edit:hover,
|
||||
.groups-delete:hover,
|
||||
.groups-new:hover {
|
||||
background: var(--ctp-mauve-hover);
|
||||
color: var(--ctp-mauve);
|
||||
}
|
||||
|
||||
.groups-edit:focus-visible,
|
||||
.groups-delete:focus-visible,
|
||||
.groups-new:focus-visible {
|
||||
outline: 2px solid var(--ctp-mauve);
|
||||
outline-offset: 2px;
|
||||
border-radius: 0.2rem;
|
||||
}
|
||||
|
||||
.groups-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.groups-error {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.permission-checkboxes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem 1.1rem;
|
||||
}
|
||||
|
||||
.permission-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.permission-option code {
|
||||
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas,
|
||||
monospace;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.groups-form-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.groups-save {
|
||||
appearance: none;
|
||||
padding: 0.55rem 1rem;
|
||||
border-radius: 0.35rem;
|
||||
border: 1px solid var(--ctp-mauve);
|
||||
background: var(--ctp-mauve);
|
||||
color: var(--ctp-base);
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 650;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.groups-save:hover {
|
||||
background: var(--ctp-mauve-hover);
|
||||
}
|
||||
|
||||
.groups-save:focus-visible {
|
||||
outline: 2px solid var(--ctp-mauve);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.config-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 0.6rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--ctp-surface1);
|
||||
max-width: 40rem;
|
||||
}
|
||||
|
||||
.config-action-revert,
|
||||
.config-action-save {
|
||||
appearance: none;
|
||||
margin: 0;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.35rem;
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 550;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.config-action-revert {
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
background: transparent;
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.config-action-revert:hover:not(:disabled) {
|
||||
background: var(--ctp-surface0);
|
||||
}
|
||||
|
||||
.config-action-save {
|
||||
border: 1px solid var(--ctp-mauve);
|
||||
background: var(--ctp-mauve);
|
||||
color: var(--ctp-base);
|
||||
}
|
||||
|
||||
.config-action-save:hover:not(:disabled) {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.config-action-revert:disabled,
|
||||
.config-action-save:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.config-action-revert:focus-visible,
|
||||
.config-action-save:focus-visible {
|
||||
outline: 2px solid var(--ctp-mauve);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.app-shell {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.wizard-shell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 1.5rem;
|
||||
background:
|
||||
radial-gradient(ellipse at top, color-mix(in srgb, var(--ctp-mauve) 18%, transparent), transparent 55%),
|
||||
var(--ctp-base);
|
||||
color: var(--ctp-text);
|
||||
font-family: 'IBM Plex Sans', 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
.wizard-card {
|
||||
width: min(560px, 100%);
|
||||
padding: 1.75rem 1.75rem 1.5rem;
|
||||
border: 1px solid var(--ctp-surface0);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--ctp-mantle);
|
||||
box-shadow: 0 12px 40px color-mix(in srgb, var(--ctp-crust) 35%, transparent);
|
||||
}
|
||||
|
||||
.wizard-brand {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ctp-mauve);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.wizard-title {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 1.6rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.wizard-step-indicator {
|
||||
margin: 0 0 1.25rem;
|
||||
color: var(--ctp-subtext0);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.wizard-body h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.wizard-body p {
|
||||
margin: 0 0 0.85rem;
|
||||
line-height: 1.5;
|
||||
color: var(--ctp-subtext1);
|
||||
}
|
||||
|
||||
.wizard-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.65rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.wizard-error {
|
||||
margin: 0 0 0.85rem;
|
||||
color: var(--ctp-red);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.wizard-rules {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.totp-setup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.85rem;
|
||||
border: 1px solid var(--ctp-surface0);
|
||||
border-radius: 0.35rem;
|
||||
background: var(--ctp-base);
|
||||
}
|
||||
|
||||
.totp-qr {
|
||||
align-self: center;
|
||||
border-radius: 0.25rem;
|
||||
background: #fff;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.totp-secret {
|
||||
display: block;
|
||||
padding: 0.5rem 0.65rem;
|
||||
border-radius: 0.25rem;
|
||||
background: var(--ctp-surface0);
|
||||
word-break: break-all;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.logs-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.logs-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.logs-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.logs-table th,
|
||||
.logs-table td {
|
||||
padding: 0.55rem 0.65rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.logs-table th {
|
||||
font-weight: 600;
|
||||
color: var(--ctp-subtext0);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.logs-table code {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+2925
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,89 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { login } from './api/client'
|
||||
|
||||
type LoginPageProps = {
|
||||
onLoggedIn: (username: string) => void
|
||||
}
|
||||
|
||||
export function LoginPage({ onLoggedIn }: LoginPageProps) {
|
||||
const [username, setUsername] = useState('Admin')
|
||||
const [password, setPassword] = useState('')
|
||||
const [totpCode, setTotpCode] = useState('')
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
setErrorMessage(null)
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const me = await login(username.trim(), password, totpCode.trim())
|
||||
onLoggedIn(me.username)
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Login failed')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="wizard-shell">
|
||||
<form className="wizard-card" onSubmit={(event) => void handleSubmit(event)}>
|
||||
<p className="wizard-brand">Cluster Canvas</p>
|
||||
<h1 className="wizard-title">Sign in</h1>
|
||||
<p className="config-hint">
|
||||
Use the administrator account created during setup.
|
||||
</p>
|
||||
|
||||
{errorMessage ? (
|
||||
<p className="wizard-error" role="alert">
|
||||
{errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="config-field">
|
||||
<label htmlFor="login-username">Login name</label>
|
||||
<input
|
||||
id="login-username"
|
||||
className="config-input"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
<div className="config-field">
|
||||
<label htmlFor="login-password">Password</label>
|
||||
<input
|
||||
id="login-password"
|
||||
className="config-input"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="config-field">
|
||||
<label htmlFor="login-totp">TOTP code (if enabled)</label>
|
||||
<input
|
||||
id="login-totp"
|
||||
className="config-input"
|
||||
value={totpCode}
|
||||
onChange={(event) => setTotpCode(event.target.value)}
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="wizard-actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="groups-save"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import type { ReauthCredentials } from './api/client'
|
||||
|
||||
type ReauthModalProps = {
|
||||
title: string
|
||||
hint?: string
|
||||
requireTotp: boolean
|
||||
confirmLabel?: string
|
||||
onCancel: () => void
|
||||
onConfirm: (credentials: ReauthCredentials) => Promise<void>
|
||||
}
|
||||
|
||||
export function ReauthModal({
|
||||
title,
|
||||
hint,
|
||||
requireTotp,
|
||||
confirmLabel = 'Confirm',
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: ReauthModalProps) {
|
||||
const [password, setPassword] = useState('')
|
||||
const [totpCode, setTotpCode] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
setError(null)
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await onConfirm({
|
||||
current_password: password,
|
||||
totp_code: totpCode,
|
||||
})
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Re-authentication failed')
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="reauth-backdrop" role="presentation">
|
||||
<div
|
||||
className="reauth-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="reauth-title"
|
||||
>
|
||||
<h2 id="reauth-title">{title}</h2>
|
||||
{hint ? <p className="config-hint">{hint}</p> : null}
|
||||
<form className="reauth-form" onSubmit={(event) => void handleSubmit(event)}>
|
||||
<label htmlFor="reauth-password">Password</label>
|
||||
<input
|
||||
id="reauth-password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
required
|
||||
/>
|
||||
{requireTotp ? (
|
||||
<>
|
||||
<label htmlFor="reauth-totp">Authenticator code</label>
|
||||
<input
|
||||
id="reauth-totp"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
value={totpCode}
|
||||
onChange={(event) => setTotpCode(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p className="groups-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="reauth-actions">
|
||||
<button type="button" className="config-action-revert" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="config-action-save"
|
||||
disabled={isSubmitting || !password}
|
||||
>
|
||||
{isSubmitting ? 'Checking…' : confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
beginSetupTOTP,
|
||||
completeSetup,
|
||||
DEFAULT_NETWORK_SETTINGS,
|
||||
DEFAULT_SECURITY_SETTINGS,
|
||||
previewNetworkAccess,
|
||||
type AccessMode,
|
||||
type NetworkSettings,
|
||||
type SecuritySettings,
|
||||
verifySetupTOTP,
|
||||
} from './api/client'
|
||||
import {
|
||||
validateHostname,
|
||||
validateListenAddress,
|
||||
validatePassword,
|
||||
} from './passwordPolicy'
|
||||
|
||||
type SetupWizardProps = {
|
||||
clientIp: string
|
||||
onCompleted: () => void
|
||||
}
|
||||
|
||||
type WizardStep = 0 | 1 | 2 | 3
|
||||
|
||||
export function SetupWizard({ clientIp, onCompleted }: SetupWizardProps) {
|
||||
const [step, setStep] = useState<WizardStep>(0)
|
||||
const [username, setUsername] = useState('Admin')
|
||||
const [password, setPassword] = useState('')
|
||||
const [passwordConfirm, setPasswordConfirm] = useState('')
|
||||
const [setupTotp, setSetupTotp] = useState(true)
|
||||
const [totpSecret, setTotpSecret] = useState('')
|
||||
const [totpUrl, setTotpUrl] = useState('')
|
||||
const [totpCode, setTotpCode] = useState('')
|
||||
const [totpConfirmed, setTotpConfirmed] = useState(false)
|
||||
const [qrDataUrl, setQrDataUrl] = useState('')
|
||||
const [network, setNetwork] = useState<NetworkSettings>(DEFAULT_NETWORK_SETTINGS)
|
||||
const [rulesText, setRulesText] = useState('')
|
||||
const [lockoutWarning, setLockoutWarning] = useState<string | null>(null)
|
||||
const [security, setSecurity] = useState<SecuritySettings>(
|
||||
DEFAULT_SECURITY_SETTINGS,
|
||||
)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!totpUrl) {
|
||||
setQrDataUrl('')
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
void import('qrcode').then(async (qrcode) => {
|
||||
const dataUrl = await qrcode.toDataURL(totpUrl, { width: 220, margin: 1 })
|
||||
if (!cancelled) {
|
||||
setQrDataUrl(dataUrl)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [totpUrl])
|
||||
|
||||
useEffect(() => {
|
||||
if (step !== 2) {
|
||||
return
|
||||
}
|
||||
const rules = rulesText
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
const draft: NetworkSettings = { ...network, rules }
|
||||
let cancelled = false
|
||||
void previewNetworkAccess(draft)
|
||||
.then((preview) => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
if (preview.would_lock_out) {
|
||||
setLockoutWarning(
|
||||
preview.reason ||
|
||||
`These rules may lock out your current IP (${preview.client_ip}).`,
|
||||
)
|
||||
} else {
|
||||
setLockoutWarning(null)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setLockoutWarning(null)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [step, network, rulesText])
|
||||
|
||||
async function handleAdminNext() {
|
||||
setErrorMessage(null)
|
||||
const passwordError = validatePassword(password, username)
|
||||
if (passwordError) {
|
||||
setErrorMessage(passwordError)
|
||||
return
|
||||
}
|
||||
if (password !== passwordConfirm) {
|
||||
setErrorMessage('Passwords do not match')
|
||||
return
|
||||
}
|
||||
|
||||
if (!setupTotp) {
|
||||
setTotpSecret('')
|
||||
setTotpConfirmed(false)
|
||||
setStep(2)
|
||||
return
|
||||
}
|
||||
|
||||
if (!totpConfirmed) {
|
||||
try {
|
||||
setIsSubmitting(true)
|
||||
if (!totpSecret) {
|
||||
const began = await beginSetupTOTP(username.trim() || 'Admin')
|
||||
setTotpSecret(began.secret)
|
||||
setTotpUrl(began.otpauth_url)
|
||||
return
|
||||
}
|
||||
if (!totpCode.trim()) {
|
||||
setErrorMessage('Enter the TOTP code from your authenticator')
|
||||
return
|
||||
}
|
||||
await verifySetupTOTP(totpSecret, totpCode.trim())
|
||||
setTotpConfirmed(true)
|
||||
setSecurity((previous) => ({ ...previous, totp_enabled: true }))
|
||||
setStep(2)
|
||||
} catch (error) {
|
||||
setErrorMessage(
|
||||
error instanceof Error ? error.message : 'TOTP setup failed',
|
||||
)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setStep(2)
|
||||
}
|
||||
|
||||
function handleNetworkNext() {
|
||||
setErrorMessage(null)
|
||||
const listenError = validateListenAddress(network.listen_address)
|
||||
if (listenError) {
|
||||
setErrorMessage(listenError)
|
||||
return
|
||||
}
|
||||
const hostnameError = validateHostname(network.public_hostname)
|
||||
if (hostnameError) {
|
||||
setErrorMessage(hostnameError)
|
||||
return
|
||||
}
|
||||
if (lockoutWarning) {
|
||||
setErrorMessage(
|
||||
`${lockoutWarning} Adjust the rules before continuing.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
const rules = rulesText
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
setNetwork((previous) => ({ ...previous, rules }))
|
||||
setStep(3)
|
||||
}
|
||||
|
||||
async function handleFinish() {
|
||||
setErrorMessage(null)
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const rules = rulesText
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
const finalSecurity = totpConfirmed
|
||||
? { ...security, totp_enabled: true }
|
||||
: security
|
||||
await completeSetup({
|
||||
username: username.trim() || 'Admin',
|
||||
password,
|
||||
totp_secret: totpConfirmed ? totpSecret : '',
|
||||
totp_confirmed: totpConfirmed,
|
||||
network: { ...network, rules },
|
||||
security: finalSecurity,
|
||||
})
|
||||
onCompleted()
|
||||
} catch (error) {
|
||||
setErrorMessage(
|
||||
error instanceof Error ? error.message : 'Setup failed',
|
||||
)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="wizard-shell">
|
||||
<div className="wizard-card">
|
||||
<p className="wizard-brand">Cluster Canvas</p>
|
||||
<h1 className="wizard-title">Setup wizard</h1>
|
||||
<p className="wizard-step-indicator">Step {step + 1} of 4</p>
|
||||
|
||||
{errorMessage ? (
|
||||
<p className="wizard-error" role="alert">
|
||||
{errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{step === 0 ? (
|
||||
<div className="wizard-body">
|
||||
<h2>Welcome</h2>
|
||||
<p>
|
||||
Before you can use Cluster Canvas, a few settings need to be
|
||||
filled in: an administrator account, network access rules, and
|
||||
server security policy.
|
||||
</p>
|
||||
<p>
|
||||
This wizard walks through those steps once. After it finishes you
|
||||
will sign in with the admin account you create.
|
||||
</p>
|
||||
<div className="wizard-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="groups-save"
|
||||
onClick={() => setStep(1)}
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 1 ? (
|
||||
<div className="wizard-body">
|
||||
<h2>Administrator account</h2>
|
||||
<div className="config-field">
|
||||
<label htmlFor="setup-username">Login name</label>
|
||||
<input
|
||||
id="setup-username"
|
||||
className="config-input"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="config-field">
|
||||
<label htmlFor="setup-password">Password</label>
|
||||
<input
|
||||
id="setup-password"
|
||||
className="config-input"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
<p className="config-hint">
|
||||
At least 14 characters, or a passphrase of at least 5 words.
|
||||
Maximum 256 characters.
|
||||
</p>
|
||||
</div>
|
||||
<div className="config-field">
|
||||
<label htmlFor="setup-password-confirm">Confirm password</label>
|
||||
<input
|
||||
id="setup-password-confirm"
|
||||
className="config-input"
|
||||
type="password"
|
||||
value={passwordConfirm}
|
||||
onChange={(event) => setPasswordConfirm(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="config-field">
|
||||
<label className="permission-option" htmlFor="setup-totp">
|
||||
<input
|
||||
id="setup-totp"
|
||||
type="checkbox"
|
||||
checked={setupTotp}
|
||||
onChange={(event) => {
|
||||
setSetupTotp(event.target.checked)
|
||||
setTotpConfirmed(false)
|
||||
setTotpSecret('')
|
||||
setTotpUrl('')
|
||||
setTotpCode('')
|
||||
}}
|
||||
/>
|
||||
Set up TOTP now (recommended)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{setupTotp && totpSecret ? (
|
||||
<div className="totp-setup">
|
||||
{qrDataUrl ? (
|
||||
<img
|
||||
className="totp-qr"
|
||||
src={qrDataUrl}
|
||||
alt="TOTP QR code"
|
||||
width={220}
|
||||
height={220}
|
||||
/>
|
||||
) : (
|
||||
<p className="config-hint">Generating QR code…</p>
|
||||
)}
|
||||
<p className="config-hint">
|
||||
Or copy this secret into your authenticator:
|
||||
</p>
|
||||
<code className="totp-secret">{totpSecret}</code>
|
||||
<button
|
||||
type="button"
|
||||
className="config-action-revert"
|
||||
onClick={() => void navigator.clipboard.writeText(totpUrl || totpSecret)}
|
||||
>
|
||||
Copy setup URI
|
||||
</button>
|
||||
<div className="config-field">
|
||||
<label htmlFor="setup-totp-code">Verification code</label>
|
||||
<input
|
||||
id="setup-totp-code"
|
||||
className="config-input"
|
||||
value={totpCode}
|
||||
onChange={(event) => setTotpCode(event.target.value)}
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="wizard-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="config-action-revert"
|
||||
onClick={() => setStep(0)}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="groups-save"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => void handleAdminNext()}
|
||||
>
|
||||
{setupTotp && !totpSecret
|
||||
? 'Continue to TOTP'
|
||||
: setupTotp && !totpConfirmed
|
||||
? 'Verify TOTP'
|
||||
: 'Next'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 2 ? (
|
||||
<div className="wizard-body">
|
||||
<h2>Network setup</h2>
|
||||
<div className="config-field">
|
||||
<label htmlFor="setup-listen">Listen address</label>
|
||||
<input
|
||||
id="setup-listen"
|
||||
className="config-input"
|
||||
value={network.listen_address}
|
||||
onChange={(event) =>
|
||||
setNetwork((previous) => ({
|
||||
...previous,
|
||||
listen_address: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<p className="config-hint">
|
||||
Default 0.0.0.0 (all interfaces). Applied on next process
|
||||
restart.
|
||||
</p>
|
||||
</div>
|
||||
<div className="config-field">
|
||||
<label htmlFor="setup-hostname">Public hostname</label>
|
||||
<input
|
||||
id="setup-hostname"
|
||||
className="config-input"
|
||||
placeholder="admin.mydomain.ext"
|
||||
value={network.public_hostname}
|
||||
onChange={(event) =>
|
||||
setNetwork((previous) => ({
|
||||
...previous,
|
||||
public_hostname: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<p className="config-hint">
|
||||
Recommended for production behind a reverse proxy (HTTPS). Leave
|
||||
empty for local/remotedev access by IP address — filling this in
|
||||
requires visiting the UI via that exact hostname.
|
||||
</p>
|
||||
</div>
|
||||
<div className="config-field">
|
||||
<label htmlFor="setup-access-mode">Access mode</label>
|
||||
<select
|
||||
id="setup-access-mode"
|
||||
className="theme-select"
|
||||
value={network.access_mode}
|
||||
onChange={(event) =>
|
||||
setNetwork((previous) => ({
|
||||
...previous,
|
||||
access_mode: event.target.value as AccessMode,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="open">Open (no IP filter)</option>
|
||||
<option value="whitelist">Whitelist (deny non-matching)</option>
|
||||
<option value="blacklist">Blacklist (deny matching)</option>
|
||||
</select>
|
||||
</div>
|
||||
{network.access_mode !== 'open' ? (
|
||||
<div className="config-field">
|
||||
<label htmlFor="setup-rules">
|
||||
IP / CIDR rules (one per line)
|
||||
</label>
|
||||
<textarea
|
||||
id="setup-rules"
|
||||
className="config-input wizard-rules"
|
||||
rows={5}
|
||||
value={rulesText}
|
||||
onChange={(event) => setRulesText(event.target.value)}
|
||||
/>
|
||||
<p className="config-hint">Your current IP: {clientIp || 'unknown'}</p>
|
||||
{lockoutWarning ? (
|
||||
<p className="wizard-error" role="status">
|
||||
{lockoutWarning}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="wizard-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="config-action-revert"
|
||||
onClick={() => setStep(1)}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="groups-save"
|
||||
onClick={handleNetworkNext}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 3 ? (
|
||||
<div className="wizard-body">
|
||||
<h2>Server settings</h2>
|
||||
<div className="config-field">
|
||||
<label htmlFor="setup-idle">Idle timeout (minutes)</label>
|
||||
<input
|
||||
id="setup-idle"
|
||||
className="config-input"
|
||||
type="number"
|
||||
min={1}
|
||||
max={240}
|
||||
value={security.idle_timeout_minutes}
|
||||
onChange={(event) =>
|
||||
setSecurity((previous) => ({
|
||||
...previous,
|
||||
idle_timeout_minutes: Number(event.target.value) || 0,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="config-field">
|
||||
<label htmlFor="setup-lifetime">Session lifetime (hours)</label>
|
||||
<input
|
||||
id="setup-lifetime"
|
||||
className="config-input"
|
||||
type="number"
|
||||
min={1}
|
||||
value={security.session_lifetime_hours}
|
||||
onChange={(event) =>
|
||||
setSecurity((previous) => ({
|
||||
...previous,
|
||||
session_lifetime_hours: Number(event.target.value) || 0,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="config-field">
|
||||
<label className="permission-option" htmlFor="setup-totp-server">
|
||||
<input
|
||||
id="setup-totp-server"
|
||||
type="checkbox"
|
||||
checked={security.totp_enabled}
|
||||
disabled={totpConfirmed}
|
||||
onChange={(event) =>
|
||||
setSecurity((previous) => ({
|
||||
...previous,
|
||||
totp_enabled: event.target.checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
Enable TOTP
|
||||
</label>
|
||||
{totpConfirmed ? (
|
||||
<p className="config-hint">
|
||||
Locked on because the admin account enrolled TOTP.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="config-field">
|
||||
<label className="permission-option" htmlFor="setup-reauth">
|
||||
<input
|
||||
id="setup-reauth"
|
||||
type="checkbox"
|
||||
checked={security.reauth_sensitive_actions}
|
||||
onChange={(event) =>
|
||||
setSecurity((previous) => ({
|
||||
...previous,
|
||||
reauth_sensitive_actions: event.target.checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
Re-auth for sensitive actions
|
||||
</label>
|
||||
</div>
|
||||
<div className="config-field">
|
||||
<label htmlFor="setup-reauth-grace">
|
||||
Re-auth grace period (minutes)
|
||||
</label>
|
||||
<input
|
||||
id="setup-reauth-grace"
|
||||
className="config-input"
|
||||
type="number"
|
||||
min={0}
|
||||
disabled={!security.reauth_sensitive_actions}
|
||||
value={security.reauth_grace_minutes}
|
||||
onChange={(event) =>
|
||||
setSecurity((previous) => ({
|
||||
...previous,
|
||||
reauth_grace_minutes: Number(event.target.value) || 0,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="wizard-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="config-action-revert"
|
||||
onClick={() => setStep(2)}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="groups-save"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => void handleFinish()}
|
||||
>
|
||||
Finish setup
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,624 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
deleteGroup,
|
||||
deleteNode,
|
||||
deleteUser,
|
||||
fetchGroups,
|
||||
fetchHealth,
|
||||
fetchNetwork,
|
||||
fetchNodeLogs,
|
||||
fetchSecurity,
|
||||
fetchStatus,
|
||||
fetchUsers,
|
||||
getApiBaseUrl,
|
||||
saveNetwork,
|
||||
saveSecurity,
|
||||
upsertGroup,
|
||||
} from './client'
|
||||
|
||||
describe('getApiBaseUrl', () => {
|
||||
it('defaults to localhost:8080 when unset', () => {
|
||||
expect(getApiBaseUrl()).toBe('http://localhost:8080')
|
||||
})
|
||||
|
||||
it('keeps an empty string for same-origin requests', async () => {
|
||||
vi.resetModules()
|
||||
vi.stubEnv('VITE_API_BASE_URL', '')
|
||||
const clientModule = await import('./client')
|
||||
expect(clientModule.getApiBaseUrl()).toBe('')
|
||||
vi.unstubAllEnvs()
|
||||
vi.resetModules()
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchHealth', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns parsed health payload', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ status: 'ok' }),
|
||||
})
|
||||
|
||||
const health = await fetchHealth(fetchMock)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8080/health',
|
||||
expect.objectContaining({ credentials: 'include' }),
|
||||
)
|
||||
expect(health).toEqual({ status: 'ok' })
|
||||
})
|
||||
|
||||
it('throws when the response is not ok', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 503,
|
||||
json: async () => ({}),
|
||||
})
|
||||
|
||||
await expect(fetchHealth(fetchMock)).rejects.toThrow(
|
||||
'health check failed: 503',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchStatus', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns parsed status payload', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ service: 'clustercanvas', version: '0.1.0' }),
|
||||
})
|
||||
|
||||
const status = await fetchStatus(fetchMock)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8080/api/v1/status',
|
||||
expect.objectContaining({ credentials: 'include' }),
|
||||
)
|
||||
expect(status).toEqual({ service: 'clustercanvas', version: '0.1.0' })
|
||||
})
|
||||
|
||||
it('throws when the response is not ok', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 503,
|
||||
json: async () => ({}),
|
||||
})
|
||||
|
||||
await expect(fetchStatus(fetchMock)).rejects.toThrow(
|
||||
'status check failed: 503',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchGroups', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns parsed groups payload', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
groups: [
|
||||
{
|
||||
name: 'Admins',
|
||||
scope_kind: 'node',
|
||||
scope_name: 'node-1',
|
||||
permissions: ['nodes.read'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
const payload = await fetchGroups(fetchMock)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8080/api/v1/groups',
|
||||
expect.objectContaining({ credentials: 'include' }),
|
||||
)
|
||||
expect(payload.groups).toEqual([
|
||||
{
|
||||
name: 'Admins',
|
||||
scope_kind: 'node',
|
||||
scope_name: 'node-1',
|
||||
permissions: ['nodes.read'],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('throws when the response is not ok', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 503,
|
||||
json: async () => ({}),
|
||||
})
|
||||
|
||||
await expect(fetchGroups(fetchMock)).rejects.toThrow(
|
||||
'groups fetch failed: 503',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('upsertGroup', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('sends JSON body and returns parsed groups', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ groups: [] }),
|
||||
})
|
||||
|
||||
await upsertGroup(
|
||||
{
|
||||
name: 'Admins',
|
||||
scope_kind: 'node',
|
||||
scope_name: 'node-1',
|
||||
permissions: ['nodes.read'],
|
||||
},
|
||||
fetchMock,
|
||||
)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8080/api/v1/groups',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
group: {
|
||||
name: 'Admins',
|
||||
scope_kind: 'node',
|
||||
scope_name: 'node-1',
|
||||
permissions: ['nodes.read'],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('throws when the response is not ok', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
json: async () => ({}),
|
||||
})
|
||||
|
||||
await expect(
|
||||
upsertGroup(
|
||||
{
|
||||
name: 'Admins',
|
||||
scope_kind: 'node',
|
||||
scope_name: 'node-1',
|
||||
permissions: ['nodes.read'],
|
||||
},
|
||||
fetchMock,
|
||||
),
|
||||
).rejects.toThrow('groups upsert failed: 400')
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteGroup', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('sends DELETE and returns parsed groups', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ groups: [] }),
|
||||
})
|
||||
|
||||
await deleteGroup('Admins', fetchMock)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8080/api/v1/groups?name=Admins',
|
||||
expect.objectContaining({ method: 'DELETE', credentials: 'include' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('throws when the response is not ok', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({}),
|
||||
})
|
||||
|
||||
await expect(deleteGroup('Admins', fetchMock)).rejects.toThrow(
|
||||
'request failed: 404',
|
||||
)
|
||||
})
|
||||
|
||||
it('surfaces API error messages', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 409,
|
||||
json: async () => ({
|
||||
error: 'Administrators group cannot be deleted',
|
||||
}),
|
||||
})
|
||||
|
||||
await expect(deleteGroup('Administrators', fetchMock)).rejects.toThrow(
|
||||
'Administrators group cannot be deleted',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchUsers', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns parsed users payload', async () => {
|
||||
const users = [
|
||||
{
|
||||
id: 'u1',
|
||||
username: 'Admin',
|
||||
enabled: true,
|
||||
totp_confirmed: false,
|
||||
group_names: ['Administrators'],
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
password_changed_at: '2026-01-01T00:00:00Z',
|
||||
is_admin: true,
|
||||
can_delete: false,
|
||||
},
|
||||
]
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ users }),
|
||||
})
|
||||
|
||||
const payload = await fetchUsers(fetchMock)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8080/api/v1/users',
|
||||
expect.objectContaining({ credentials: 'include' }),
|
||||
)
|
||||
expect(payload.users).toEqual(users)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteUser', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('sends DELETE by id with reauth credentials and returns users', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ users: [] }),
|
||||
})
|
||||
|
||||
await deleteUser(
|
||||
'u2',
|
||||
{ current_password: 'correct horse battery staple extra' },
|
||||
fetchMock,
|
||||
)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8080/api/v1/users?id=u2',
|
||||
expect.objectContaining({
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
current_password: 'correct horse battery staple extra',
|
||||
totp_code: '',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('surfaces last-admin errors', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 409,
|
||||
json: async () => ({ error: 'cannot delete the last administrator' }),
|
||||
})
|
||||
|
||||
await expect(
|
||||
deleteUser('u1', { current_password: 'x' }, fetchMock),
|
||||
).rejects.toThrow('cannot delete the last administrator')
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteNode', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('sends DELETE by id with reauth credentials and returns nodes', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ nodes: [] }),
|
||||
})
|
||||
|
||||
await deleteNode(
|
||||
'node-1',
|
||||
{ current_password: 'correct horse battery staple extra' },
|
||||
fetchMock,
|
||||
)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8080/api/v1/nodes/node-1',
|
||||
expect.objectContaining({
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
current_password: 'correct horse battery staple extra',
|
||||
totp_code: '',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchNodeLogs', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns parsed logs payload with optional kind filter', async () => {
|
||||
const events = [
|
||||
{
|
||||
id: 'e1',
|
||||
at: '2026-01-01T00:00:00Z',
|
||||
action: 'create',
|
||||
actor: 'Admin',
|
||||
node_id: 'n1',
|
||||
node_name: 'ct1',
|
||||
node_kind: 'container',
|
||||
},
|
||||
]
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ events }),
|
||||
})
|
||||
|
||||
const payload = await fetchNodeLogs('container', fetchMock)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8080/api/v1/node-logs?kind=container',
|
||||
expect.objectContaining({ credentials: 'include' }),
|
||||
)
|
||||
expect(payload.events).toEqual(events)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchSecurity', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns parsed security payload', async () => {
|
||||
const security = {
|
||||
idle_timeout_minutes: 30,
|
||||
session_lifetime_hours: 24,
|
||||
reauth_sensitive_actions: false,
|
||||
reauth_grace_minutes: 15,
|
||||
totp_enabled: false,
|
||||
}
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ security }),
|
||||
})
|
||||
|
||||
const payload = await fetchSecurity(fetchMock)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8080/api/v1/security',
|
||||
expect.objectContaining({ credentials: 'include' }),
|
||||
)
|
||||
expect(payload.security).toEqual(security)
|
||||
})
|
||||
|
||||
it('throws when the response is not ok', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 503,
|
||||
json: async () => ({}),
|
||||
})
|
||||
|
||||
await expect(fetchSecurity(fetchMock)).rejects.toThrow(
|
||||
'security fetch failed: 503',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('saveSecurity', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('sends JSON body and returns parsed security', async () => {
|
||||
const security = {
|
||||
idle_timeout_minutes: 45,
|
||||
session_lifetime_hours: 12,
|
||||
reauth_sensitive_actions: true,
|
||||
reauth_grace_minutes: 0,
|
||||
totp_enabled: true,
|
||||
}
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ security }),
|
||||
})
|
||||
|
||||
await saveSecurity(security, fetchMock)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8080/api/v1/security',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ security }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('throws when the response is not ok', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
json: async () => ({}),
|
||||
})
|
||||
|
||||
await expect(
|
||||
saveSecurity(
|
||||
{
|
||||
idle_timeout_minutes: 0,
|
||||
session_lifetime_hours: 24,
|
||||
reauth_sensitive_actions: false,
|
||||
reauth_grace_minutes: 15,
|
||||
totp_enabled: false,
|
||||
},
|
||||
fetchMock,
|
||||
),
|
||||
).rejects.toThrow('security save failed: 400')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchNetwork', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns parsed network payload', async () => {
|
||||
const network = {
|
||||
listen_address: '0.0.0.0',
|
||||
public_hostname: '',
|
||||
access_mode: 'open' as const,
|
||||
rules: [],
|
||||
}
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ network }),
|
||||
})
|
||||
|
||||
const payload = await fetchNetwork(fetchMock)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8080/api/v1/network',
|
||||
expect.objectContaining({ credentials: 'include' }),
|
||||
)
|
||||
expect(payload.network).toEqual(network)
|
||||
})
|
||||
})
|
||||
|
||||
describe('saveNetwork', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('sends JSON body and returns parsed network', async () => {
|
||||
const network = {
|
||||
listen_address: '127.0.0.1',
|
||||
public_hostname: 'admin.example.com',
|
||||
access_mode: 'whitelist' as const,
|
||||
rules: ['10.0.0.0/8'],
|
||||
}
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ network }),
|
||||
})
|
||||
|
||||
await saveNetwork(network, fetchMock)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8080/api/v1/network',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ network }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('nodes API', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('fetches nodes filtered by kind', async () => {
|
||||
const { fetchNodes } = await import('./client')
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ nodes: [] }),
|
||||
})
|
||||
|
||||
await fetchNodes('container', fetchMock)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8080/api/v1/nodes?kind=container',
|
||||
expect.objectContaining({ credentials: 'include' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('creates a node with generate options', async () => {
|
||||
const { createNode } = await import('./client')
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
node: {
|
||||
id: '11111111-2222-4333-8444-555555555555',
|
||||
kind: 'container',
|
||||
name: 'ct1',
|
||||
host_ip: '10.0.0.1',
|
||||
username: 'clustercanvas',
|
||||
group_name: 'Administrators',
|
||||
public_key: 'ssh-ed25519 AAAA',
|
||||
key_algo: 'ed25519',
|
||||
created_at: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
await createNode(
|
||||
{
|
||||
kind: 'container',
|
||||
name: 'ct1',
|
||||
host_ip: '10.0.0.1',
|
||||
username: 'clustercanvas',
|
||||
group_name: 'Administrators',
|
||||
generate: { algorithm: 'ed25519', kdf_rounds: 100 },
|
||||
},
|
||||
fetchMock,
|
||||
)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8080/api/v1/nodes',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('posts to the SSH test endpoint', async () => {
|
||||
const { testNodeSSH } = await import('./client')
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ ok: false, message: 'connection refused' }),
|
||||
})
|
||||
|
||||
const result = await testNodeSSH(
|
||||
'11111111-2222-4333-8444-555555555555',
|
||||
fetchMock,
|
||||
)
|
||||
|
||||
expect(result).toEqual({ ok: false, message: 'connection refused' })
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8080/api/v1/nodes/11111111-2222-4333-8444-555555555555/test-ssh',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,762 @@
|
||||
export type HealthResponse = {
|
||||
status: string
|
||||
}
|
||||
|
||||
export type StatusResponse = {
|
||||
service: string
|
||||
version: string
|
||||
}
|
||||
|
||||
export type GroupScopeKind = 'node' | 'group'
|
||||
|
||||
export type Group = {
|
||||
name: string
|
||||
scope_kind: GroupScopeKind
|
||||
scope_name: string
|
||||
permissions: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
export type GroupsResponse = {
|
||||
groups: ReadonlyArray<Group>
|
||||
}
|
||||
|
||||
export type User = {
|
||||
id: string
|
||||
username: string
|
||||
enabled: boolean
|
||||
totp_confirmed: boolean
|
||||
group_names: ReadonlyArray<string>
|
||||
created_at: string
|
||||
password_changed_at: string
|
||||
is_admin: boolean
|
||||
can_delete: boolean
|
||||
}
|
||||
|
||||
export type UsersResponse = {
|
||||
users: ReadonlyArray<User>
|
||||
}
|
||||
|
||||
export type SecuritySettings = {
|
||||
idle_timeout_minutes: number
|
||||
session_lifetime_hours: number
|
||||
reauth_sensitive_actions: boolean
|
||||
reauth_grace_minutes: number
|
||||
totp_enabled: boolean
|
||||
}
|
||||
|
||||
export type SecurityResponse = {
|
||||
security: SecuritySettings
|
||||
}
|
||||
|
||||
export type NetworkResponse = {
|
||||
network: NetworkSettings
|
||||
}
|
||||
|
||||
export type AccessMode = 'open' | 'whitelist' | 'blacklist'
|
||||
|
||||
export type NetworkSettings = {
|
||||
listen_address: string
|
||||
public_hostname: string
|
||||
access_mode: AccessMode
|
||||
rules: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
export type SetupStatusResponse = {
|
||||
completed: boolean
|
||||
client_ip: string
|
||||
}
|
||||
|
||||
export type SetupTOTPBeginResponse = {
|
||||
secret: string
|
||||
otpauth_url: string
|
||||
}
|
||||
|
||||
export type SetupCompleteRequest = {
|
||||
username: string
|
||||
password: string
|
||||
totp_secret: string
|
||||
totp_confirmed: boolean
|
||||
network: NetworkSettings
|
||||
security: SecuritySettings
|
||||
}
|
||||
|
||||
export type MeResponse = {
|
||||
user_id: string
|
||||
username: string
|
||||
groups: ReadonlyArray<string>
|
||||
totp_confirmed: boolean
|
||||
totp_enabled: boolean
|
||||
permissions: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
export type ReauthCredentials = {
|
||||
current_password: string
|
||||
totp_code?: string
|
||||
}
|
||||
|
||||
export type CreateUserRequest = {
|
||||
username: string
|
||||
password: string
|
||||
group_names?: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
export type PatchUserRequest = {
|
||||
group_names?: ReadonlyArray<string>
|
||||
password?: string
|
||||
disable_totp?: boolean
|
||||
}
|
||||
|
||||
export const REAUTH_REQUIRED = 'reauth_required'
|
||||
|
||||
export function isReauthRequiredError(error: unknown): boolean {
|
||||
return error instanceof Error && error.message === REAUTH_REQUIRED
|
||||
}
|
||||
|
||||
export type NetworkPreviewResponse = {
|
||||
would_lock_out: boolean
|
||||
reason?: string
|
||||
client_ip: string
|
||||
}
|
||||
|
||||
export const DEFAULT_SECURITY_SETTINGS: SecuritySettings = {
|
||||
idle_timeout_minutes: 30,
|
||||
session_lifetime_hours: 24,
|
||||
reauth_sensitive_actions: false,
|
||||
reauth_grace_minutes: 15,
|
||||
totp_enabled: false,
|
||||
}
|
||||
|
||||
export const DEFAULT_NETWORK_SETTINGS: NetworkSettings = {
|
||||
listen_address: '0.0.0.0',
|
||||
public_hostname: '',
|
||||
access_mode: 'open',
|
||||
rules: [],
|
||||
}
|
||||
|
||||
export function getApiBaseUrl(): string {
|
||||
// Empty string means same-origin (used by remotedev / reverse-proxied builds).
|
||||
const configuredBaseUrl = import.meta.env.VITE_API_BASE_URL
|
||||
if (configuredBaseUrl === undefined) {
|
||||
return 'http://localhost:8080'
|
||||
}
|
||||
return configuredBaseUrl
|
||||
}
|
||||
|
||||
async function readErrorMessage(response: Response): Promise<string> {
|
||||
try {
|
||||
const payload = (await response.json()) as { error?: string }
|
||||
if (payload.error) {
|
||||
return payload.error
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return `request failed: ${response.status}`
|
||||
}
|
||||
|
||||
async function apiFetch(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<Response> {
|
||||
const headers = new Headers(init.headers)
|
||||
if (init.body && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
return fetchImpl(`${getApiBaseUrl()}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchHealth(
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<HealthResponse> {
|
||||
const response = await apiFetch('/health', {}, fetchImpl)
|
||||
if (!response.ok) {
|
||||
throw new Error(`health check failed: ${response.status}`)
|
||||
}
|
||||
return (await response.json()) as HealthResponse
|
||||
}
|
||||
|
||||
export async function fetchStatus(
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<StatusResponse> {
|
||||
const response = await apiFetch('/api/v1/status', {}, fetchImpl)
|
||||
if (!response.ok) {
|
||||
throw new Error(`status check failed: ${response.status}`)
|
||||
}
|
||||
return (await response.json()) as StatusResponse
|
||||
}
|
||||
|
||||
export async function fetchGroups(
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<GroupsResponse> {
|
||||
const response = await apiFetch('/api/v1/groups', {}, fetchImpl)
|
||||
if (!response.ok) {
|
||||
throw new Error(`groups fetch failed: ${response.status}`)
|
||||
}
|
||||
return (await response.json()) as GroupsResponse
|
||||
}
|
||||
|
||||
export async function upsertGroup(
|
||||
group: Group,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<GroupsResponse> {
|
||||
const response = await apiFetch(
|
||||
'/api/v1/groups',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ group }),
|
||||
},
|
||||
fetchImpl,
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`groups upsert failed: ${response.status}`)
|
||||
}
|
||||
|
||||
return (await response.json()) as GroupsResponse
|
||||
}
|
||||
|
||||
export async function deleteGroup(
|
||||
name: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<GroupsResponse> {
|
||||
const response = await apiFetch(
|
||||
`/api/v1/groups?name=${encodeURIComponent(name)}`,
|
||||
{ method: 'DELETE' },
|
||||
fetchImpl,
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
|
||||
return (await response.json()) as GroupsResponse
|
||||
}
|
||||
|
||||
export async function fetchUsers(
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<UsersResponse> {
|
||||
const response = await apiFetch('/api/v1/users', {}, fetchImpl)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as UsersResponse
|
||||
}
|
||||
|
||||
export async function createUser(
|
||||
payload: CreateUserRequest,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<UsersResponse> {
|
||||
const response = await apiFetch(
|
||||
'/api/v1/users',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
username: payload.username,
|
||||
password: payload.password,
|
||||
group_names: payload.group_names ?? [],
|
||||
}),
|
||||
},
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as UsersResponse
|
||||
}
|
||||
|
||||
export async function patchUser(
|
||||
id: string,
|
||||
payload: PatchUserRequest,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<UsersResponse> {
|
||||
const response = await apiFetch(
|
||||
`/api/v1/users?id=${encodeURIComponent(id)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as UsersResponse
|
||||
}
|
||||
|
||||
export async function deleteUser(
|
||||
id: string,
|
||||
credentials: ReauthCredentials,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<UsersResponse> {
|
||||
const response = await apiFetch(
|
||||
`/api/v1/users?id=${encodeURIComponent(id)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({
|
||||
current_password: credentials.current_password,
|
||||
totp_code: credentials.totp_code ?? '',
|
||||
}),
|
||||
},
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as UsersResponse
|
||||
}
|
||||
|
||||
export async function reauth(
|
||||
password: string,
|
||||
totpCode = '',
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<void> {
|
||||
const response = await apiFetch(
|
||||
'/api/v1/auth/reauth',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password, totp_code: totpCode }),
|
||||
},
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
}
|
||||
|
||||
export async function changeMyPassword(
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
totpCode = '',
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<void> {
|
||||
const response = await apiFetch(
|
||||
'/api/v1/me/password',
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
totp_code: totpCode,
|
||||
}),
|
||||
},
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
}
|
||||
|
||||
export async function beginMyTOTP(
|
||||
credentials: ReauthCredentials,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<SetupTOTPBeginResponse> {
|
||||
const response = await apiFetch(
|
||||
'/api/v1/me/totp/begin',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
current_password: credentials.current_password,
|
||||
totp_code: credentials.totp_code ?? '',
|
||||
}),
|
||||
},
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as SetupTOTPBeginResponse
|
||||
}
|
||||
|
||||
export async function confirmMyTOTP(
|
||||
secret: string,
|
||||
code: string,
|
||||
credentials: ReauthCredentials,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<void> {
|
||||
const response = await apiFetch(
|
||||
'/api/v1/me/totp/confirm',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
secret,
|
||||
code,
|
||||
current_password: credentials.current_password,
|
||||
totp_code: credentials.totp_code ?? '',
|
||||
}),
|
||||
},
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
}
|
||||
|
||||
export async function disableMyTOTP(
|
||||
credentials: ReauthCredentials,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<void> {
|
||||
const response = await apiFetch(
|
||||
'/api/v1/me/totp/disable',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
current_password: credentials.current_password,
|
||||
totp_code: credentials.totp_code ?? '',
|
||||
}),
|
||||
},
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchSecurity(
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<SecurityResponse> {
|
||||
const response = await apiFetch('/api/v1/security', {}, fetchImpl)
|
||||
if (!response.ok) {
|
||||
throw new Error(`security fetch failed: ${response.status}`)
|
||||
}
|
||||
return (await response.json()) as SecurityResponse
|
||||
}
|
||||
|
||||
export async function saveSecurity(
|
||||
security: SecuritySettings,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<SecurityResponse> {
|
||||
const response = await apiFetch(
|
||||
'/api/v1/security',
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ security }),
|
||||
},
|
||||
fetchImpl,
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`security save failed: ${response.status}`)
|
||||
}
|
||||
|
||||
return (await response.json()) as SecurityResponse
|
||||
}
|
||||
|
||||
export async function fetchNetwork(
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<NetworkResponse> {
|
||||
const response = await apiFetch('/api/v1/network', {}, fetchImpl)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as NetworkResponse
|
||||
}
|
||||
|
||||
export async function saveNetwork(
|
||||
network: NetworkSettings,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<NetworkResponse> {
|
||||
const response = await apiFetch(
|
||||
'/api/v1/network',
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ network }),
|
||||
},
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as NetworkResponse
|
||||
}
|
||||
|
||||
export async function fetchSetupStatus(
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<SetupStatusResponse> {
|
||||
const response = await apiFetch('/api/v1/setup/status', {}, fetchImpl)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as SetupStatusResponse
|
||||
}
|
||||
|
||||
export async function beginSetupTOTP(
|
||||
username: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<SetupTOTPBeginResponse> {
|
||||
const response = await apiFetch(
|
||||
'/api/v1/setup/totp/begin',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username }),
|
||||
},
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as SetupTOTPBeginResponse
|
||||
}
|
||||
|
||||
export async function verifySetupTOTP(
|
||||
secret: string,
|
||||
code: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<void> {
|
||||
const response = await apiFetch(
|
||||
'/api/v1/setup/totp/verify',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ secret, code }),
|
||||
},
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
}
|
||||
|
||||
export async function previewNetworkAccess(
|
||||
network: NetworkSettings,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<NetworkPreviewResponse> {
|
||||
const response = await apiFetch(
|
||||
'/api/v1/setup/network/preview',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ network }),
|
||||
},
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as NetworkPreviewResponse
|
||||
}
|
||||
|
||||
export async function completeSetup(
|
||||
payload: SetupCompleteRequest,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<void> {
|
||||
const response = await apiFetch(
|
||||
'/api/v1/setup/complete',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
}
|
||||
|
||||
export async function login(
|
||||
username: string,
|
||||
password: string,
|
||||
totpCode = '',
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<MeResponse> {
|
||||
const response = await apiFetch(
|
||||
'/api/v1/auth/login',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password,
|
||||
totp_code: totpCode,
|
||||
}),
|
||||
},
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as MeResponse
|
||||
}
|
||||
|
||||
export async function logout(
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<void> {
|
||||
const response = await apiFetch(
|
||||
'/api/v1/auth/logout',
|
||||
{ method: 'POST' },
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMe(
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<MeResponse | null> {
|
||||
const response = await apiFetch('/api/v1/auth/me', {}, fetchImpl)
|
||||
if (response.status === 401) {
|
||||
return null
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as MeResponse
|
||||
}
|
||||
|
||||
export type NodeKind = 'container' | 'vm' | 'docker'
|
||||
|
||||
export type Node = {
|
||||
id: string
|
||||
kind: NodeKind
|
||||
name: string
|
||||
host_ip: string
|
||||
username: string
|
||||
group_name: string
|
||||
public_key: string
|
||||
key_algo: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type NodesResponse = {
|
||||
nodes: ReadonlyArray<Node>
|
||||
}
|
||||
|
||||
export type NodeResponse = {
|
||||
node: Node
|
||||
}
|
||||
|
||||
export type CreateNodeGenerate = {
|
||||
algorithm: 'ed25519' | 'rsa'
|
||||
rsa_bits?: number
|
||||
kdf_rounds?: number
|
||||
}
|
||||
|
||||
export type CreateNodeRequest = {
|
||||
id?: string
|
||||
kind: NodeKind
|
||||
name: string
|
||||
host_ip: string
|
||||
username: string
|
||||
group_name?: string
|
||||
new_group?: { name: string }
|
||||
generate?: CreateNodeGenerate
|
||||
private_key?: string
|
||||
}
|
||||
|
||||
export async function fetchNodes(
|
||||
kind?: NodeKind,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<NodesResponse> {
|
||||
const query = kind ? `?kind=${encodeURIComponent(kind)}` : ''
|
||||
const response = await apiFetch(`/api/v1/nodes${query}`, {}, fetchImpl)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as NodesResponse
|
||||
}
|
||||
|
||||
export async function createNode(
|
||||
payload: CreateNodeRequest,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<NodeResponse> {
|
||||
const response = await apiFetch(
|
||||
'/api/v1/nodes',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as NodeResponse
|
||||
}
|
||||
|
||||
export async function deleteNode(
|
||||
id: string,
|
||||
credentials: ReauthCredentials,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<NodesResponse> {
|
||||
const response = await apiFetch(
|
||||
`/api/v1/nodes/${encodeURIComponent(id)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({
|
||||
current_password: credentials.current_password,
|
||||
totp_code: credentials.totp_code ?? '',
|
||||
}),
|
||||
},
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as NodesResponse
|
||||
}
|
||||
|
||||
export async function fetchNode(
|
||||
id: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<NodeResponse> {
|
||||
const response = await apiFetch(
|
||||
`/api/v1/nodes/${encodeURIComponent(id)}`,
|
||||
{},
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as NodeResponse
|
||||
}
|
||||
|
||||
export type NodeSSHTestResponse = {
|
||||
ok: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
export async function testNodeSSH(
|
||||
id: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<NodeSSHTestResponse> {
|
||||
const response = await apiFetch(
|
||||
`/api/v1/nodes/${encodeURIComponent(id)}/test-ssh`,
|
||||
{ method: 'POST' },
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as NodeSSHTestResponse
|
||||
}
|
||||
|
||||
export type NodeAuditAction = 'create' | 'delete' | 'test_ssh' | 'update'
|
||||
|
||||
export type NodeAuditEvent = {
|
||||
id: string
|
||||
at: string
|
||||
action: NodeAuditAction
|
||||
actor: string
|
||||
node_id: string
|
||||
node_name: string
|
||||
node_kind: NodeKind
|
||||
detail?: string
|
||||
}
|
||||
|
||||
export type NodeLogsResponse = {
|
||||
events: ReadonlyArray<NodeAuditEvent>
|
||||
}
|
||||
|
||||
export async function fetchNodeLogs(
|
||||
kind?: NodeKind,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<NodeLogsResponse> {
|
||||
const query = kind ? `?kind=${encodeURIComponent(kind)}` : ''
|
||||
const response = await apiFetch(`/api/v1/node-logs${query}`, {}, fetchImpl)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as NodeLogsResponse
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,69 @@
|
||||
/* Catppuccin Latte (light) — default until JS sets data-theme */
|
||||
:root,
|
||||
[data-theme='latte'] {
|
||||
--ctp-base: #eff1f5;
|
||||
--ctp-mantle: #e6e9ef;
|
||||
--ctp-crust: #dce0e8;
|
||||
--ctp-surface0: #ccd0da;
|
||||
--ctp-surface1: #bcc0cc;
|
||||
--ctp-overlay0: #9ca0b0;
|
||||
--ctp-overlay1: #8c8fa1;
|
||||
--ctp-text: #4c4f69;
|
||||
--ctp-subtext0: #6c6f85;
|
||||
--ctp-subtext1: #5c5f77;
|
||||
--ctp-mauve: #8839ef;
|
||||
--ctp-mauve-muted: color-mix(in srgb, var(--ctp-mauve) 18%, var(--ctp-base));
|
||||
--ctp-mauve-hover: color-mix(in srgb, var(--ctp-mauve) 12%, var(--ctp-base));
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* Catppuccin Mocha (dark) */
|
||||
[data-theme='mocha'] {
|
||||
--ctp-base: #1e1e2e;
|
||||
--ctp-mantle: #181825;
|
||||
--ctp-crust: #11111b;
|
||||
--ctp-surface0: #313244;
|
||||
--ctp-surface1: #45475a;
|
||||
--ctp-overlay0: #6c7086;
|
||||
--ctp-overlay1: #7f849c;
|
||||
--ctp-text: #cdd6f4;
|
||||
--ctp-subtext0: #a6adc8;
|
||||
--ctp-subtext1: #bac2de;
|
||||
--ctp-mauve: #cba6f7;
|
||||
--ctp-mauve-muted: color-mix(in srgb, var(--ctp-mauve) 22%, var(--ctp-base));
|
||||
--ctp-mauve-hover: color-mix(in srgb, var(--ctp-mauve) 14%, var(--ctp-base));
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
:root {
|
||||
color: var(--ctp-text);
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at top left,
|
||||
color-mix(in srgb, var(--ctp-mauve) 22%, transparent) 0%,
|
||||
transparent 45%
|
||||
),
|
||||
linear-gradient(180deg, var(--ctp-base) 0%, var(--ctp-mantle) 100%);
|
||||
font-family: 'IBM Plex Sans', 'Segoe UI', sans-serif;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
transition:
|
||||
color 160ms ease,
|
||||
background-color 160ms ease;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { applyThemeFromPreference } from './theme.ts'
|
||||
|
||||
applyThemeFromPreference()
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseLocation, pathFor } from './navigation'
|
||||
|
||||
describe('pathFor', () => {
|
||||
it('maps sections and config tabs to paths', () => {
|
||||
expect(pathFor('overview')).toBe('/')
|
||||
expect(pathFor('vms')).toBe('/vms')
|
||||
expect(pathFor('docker')).toBe('/docker')
|
||||
expect(pathFor('containers')).toBe('/containers')
|
||||
expect(pathFor('vms', 'overview', 'security')).toBe('/vms/security')
|
||||
expect(pathFor('containers', 'overview', 'add')).toBe('/containers/add')
|
||||
expect(pathFor('docker', 'overview', 'add')).toBe('/docker/add')
|
||||
expect(pathFor('profile')).toBe('/profile')
|
||||
expect(pathFor('logs')).toBe('/logs')
|
||||
expect(pathFor('setup')).toBe('/setup')
|
||||
expect(pathFor('login')).toBe('/login')
|
||||
expect(pathFor('configuration')).toBe('/configuration')
|
||||
expect(pathFor('configuration', 'overview')).toBe('/configuration')
|
||||
expect(pathFor('configuration', 'groups')).toBe('/configuration/groups')
|
||||
expect(pathFor('configuration', 'roles')).toBe('/configuration/roles')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseLocation', () => {
|
||||
it('round-trips known paths', () => {
|
||||
for (const path of [
|
||||
'/',
|
||||
'/vms',
|
||||
'/docker',
|
||||
'/containers',
|
||||
'/vms/security',
|
||||
'/containers/add',
|
||||
'/docker/overview',
|
||||
'/profile',
|
||||
'/logs',
|
||||
'/setup',
|
||||
'/login',
|
||||
'/configuration',
|
||||
'/configuration/users',
|
||||
'/configuration/roles',
|
||||
'/configuration/groups',
|
||||
'/configuration/security',
|
||||
'/configuration/integrations',
|
||||
'/configuration/network',
|
||||
'/configuration/advanced',
|
||||
]) {
|
||||
const location = parseLocation(path)
|
||||
expect(
|
||||
pathFor(location.section, location.configTab, location.resourceTab),
|
||||
).toBe(path === '/docker/overview' ? '/docker' : path)
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to overview for unknown paths', () => {
|
||||
expect(parseLocation('/nope')).toEqual({
|
||||
section: 'overview',
|
||||
configTab: 'overview',
|
||||
resourceTab: 'overview',
|
||||
})
|
||||
expect(parseLocation('/configuration/unknown')).toEqual({
|
||||
section: 'overview',
|
||||
configTab: 'overview',
|
||||
resourceTab: 'overview',
|
||||
})
|
||||
expect(parseLocation('/vms/extra')).toEqual({
|
||||
section: 'overview',
|
||||
configTab: 'overview',
|
||||
resourceTab: 'overview',
|
||||
})
|
||||
expect(parseLocation('/docker/extra')).toEqual({
|
||||
section: 'overview',
|
||||
configTab: 'overview',
|
||||
resourceTab: 'overview',
|
||||
})
|
||||
})
|
||||
|
||||
it('treats trailing slashes as the same path', () => {
|
||||
expect(parseLocation('/configuration/groups/')).toEqual({
|
||||
section: 'configuration',
|
||||
configTab: 'groups',
|
||||
resourceTab: 'overview',
|
||||
})
|
||||
expect(parseLocation('/containers/add/')).toEqual({
|
||||
section: 'containers',
|
||||
configTab: 'overview',
|
||||
resourceTab: 'add',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,197 @@
|
||||
export type SectionId =
|
||||
| 'overview'
|
||||
| 'vms'
|
||||
| 'docker'
|
||||
| 'containers'
|
||||
| 'logs'
|
||||
| 'profile'
|
||||
| 'configuration'
|
||||
| 'setup'
|
||||
| 'login'
|
||||
|
||||
export type ConfigTabId =
|
||||
| 'overview'
|
||||
| 'users'
|
||||
| 'roles'
|
||||
| 'groups'
|
||||
| 'security'
|
||||
| 'integrations'
|
||||
| 'network'
|
||||
| 'advanced'
|
||||
|
||||
export type ResourceTabId = 'overview' | 'security' | 'add'
|
||||
|
||||
export type ResourceSectionId = 'containers' | 'vms' | 'docker'
|
||||
|
||||
export type AppLocation = {
|
||||
section: SectionId
|
||||
configTab: ConfigTabId
|
||||
resourceTab: ResourceTabId
|
||||
}
|
||||
|
||||
const CONFIG_TAB_IDS: ReadonlySet<string> = new Set([
|
||||
'overview',
|
||||
'users',
|
||||
'roles',
|
||||
'groups',
|
||||
'security',
|
||||
'integrations',
|
||||
'network',
|
||||
'advanced',
|
||||
])
|
||||
|
||||
const RESOURCE_TAB_IDS: ReadonlySet<string> = new Set([
|
||||
'overview',
|
||||
'security',
|
||||
'add',
|
||||
])
|
||||
|
||||
const RESOURCE_SECTION_IDS: ReadonlySet<string> = new Set([
|
||||
'containers',
|
||||
'vms',
|
||||
'docker',
|
||||
])
|
||||
|
||||
const DEFAULT_LOCATION: AppLocation = {
|
||||
section: 'overview',
|
||||
configTab: 'overview',
|
||||
resourceTab: 'overview',
|
||||
}
|
||||
|
||||
function isConfigTabId(value: string): value is ConfigTabId {
|
||||
return CONFIG_TAB_IDS.has(value)
|
||||
}
|
||||
|
||||
function isResourceTabId(value: string): value is ResourceTabId {
|
||||
return RESOURCE_TAB_IDS.has(value)
|
||||
}
|
||||
|
||||
export function isResourceSectionId(value: SectionId): value is ResourceSectionId {
|
||||
return RESOURCE_SECTION_IDS.has(value)
|
||||
}
|
||||
|
||||
/** Parse a pathname into section + config/resource tab. Unknown paths fall back to Overview. */
|
||||
export function parseLocation(pathname: string): AppLocation {
|
||||
const normalized = pathname.replace(/\/+$/, '') || '/'
|
||||
const segments = normalized.split('/').filter(Boolean)
|
||||
|
||||
if (segments.length === 0) {
|
||||
return { ...DEFAULT_LOCATION }
|
||||
}
|
||||
|
||||
const [first, second] = segments
|
||||
|
||||
if (first === 'setup' && segments.length === 1) {
|
||||
return { section: 'setup', configTab: 'overview', resourceTab: 'overview' }
|
||||
}
|
||||
|
||||
if (first === 'login' && segments.length === 1) {
|
||||
return { section: 'login', configTab: 'overview', resourceTab: 'overview' }
|
||||
}
|
||||
|
||||
if (first === 'profile' && segments.length === 1) {
|
||||
return { section: 'profile', configTab: 'overview', resourceTab: 'overview' }
|
||||
}
|
||||
|
||||
if (first === 'logs' && segments.length === 1) {
|
||||
return { section: 'logs', configTab: 'overview', resourceTab: 'overview' }
|
||||
}
|
||||
|
||||
if (RESOURCE_SECTION_IDS.has(first)) {
|
||||
const section = first as ResourceSectionId
|
||||
if (segments.length === 1) {
|
||||
return { section, configTab: 'overview', resourceTab: 'overview' }
|
||||
}
|
||||
if (segments.length === 2 && isResourceTabId(second)) {
|
||||
return { section, configTab: 'overview', resourceTab: second }
|
||||
}
|
||||
return { ...DEFAULT_LOCATION }
|
||||
}
|
||||
|
||||
if (first === 'configuration') {
|
||||
if (segments.length === 1) {
|
||||
return {
|
||||
section: 'configuration',
|
||||
configTab: 'overview',
|
||||
resourceTab: 'overview',
|
||||
}
|
||||
}
|
||||
if (segments.length === 2 && isConfigTabId(second)) {
|
||||
return {
|
||||
section: 'configuration',
|
||||
configTab: second,
|
||||
resourceTab: 'overview',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ...DEFAULT_LOCATION }
|
||||
}
|
||||
|
||||
/** Build the pathname for a section (and optional config or resource tab). */
|
||||
export function pathFor(
|
||||
section: SectionId,
|
||||
configTab: ConfigTabId = 'overview',
|
||||
resourceTab: ResourceTabId = 'overview',
|
||||
): string {
|
||||
if (section === 'overview') {
|
||||
return '/'
|
||||
}
|
||||
if (section === 'setup') {
|
||||
return '/setup'
|
||||
}
|
||||
if (section === 'login') {
|
||||
return '/login'
|
||||
}
|
||||
if (section === 'profile') {
|
||||
return '/profile'
|
||||
}
|
||||
if (section === 'logs') {
|
||||
return '/logs'
|
||||
}
|
||||
if (isResourceSectionId(section)) {
|
||||
if (resourceTab === 'overview') {
|
||||
return `/${section}`
|
||||
}
|
||||
return `/${section}/${resourceTab}`
|
||||
}
|
||||
if (configTab === 'overview') {
|
||||
return '/configuration'
|
||||
}
|
||||
return `/configuration/${configTab}`
|
||||
}
|
||||
|
||||
type LocationListener = (pathname: string) => void
|
||||
|
||||
const locationListeners = new Set<LocationListener>()
|
||||
|
||||
function notifyLocationListeners(pathname: string) {
|
||||
for (const listener of locationListeners) {
|
||||
listener(pathname)
|
||||
}
|
||||
}
|
||||
|
||||
/** Push a new history entry and notify subscribers (no document reload). */
|
||||
export function navigateTo(path: string): void {
|
||||
if (window.location.pathname === path) {
|
||||
return
|
||||
}
|
||||
window.history.pushState(null, '', path)
|
||||
notifyLocationListeners(path)
|
||||
}
|
||||
|
||||
/** Subscribe to pathname changes from pushState navigate and browser popstate. */
|
||||
export function subscribeToLocation(listener: LocationListener): () => void {
|
||||
locationListeners.add(listener)
|
||||
|
||||
function handlePopState() {
|
||||
listener(window.location.pathname)
|
||||
}
|
||||
|
||||
window.addEventListener('popstate', handlePopState)
|
||||
|
||||
return () => {
|
||||
locationListeners.delete(listener)
|
||||
window.removeEventListener('popstate', handlePopState)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { validateHostname, validatePassword } from './passwordPolicy'
|
||||
|
||||
describe('validatePassword', () => {
|
||||
it('accepts long passwords and passphrases', () => {
|
||||
expect(validatePassword('Tr0ub4dor&3-extra!', 'Admin')).toBeNull()
|
||||
expect(
|
||||
validatePassword('apple banana cherry date elderberry', 'Admin'),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects weak values', () => {
|
||||
expect(validatePassword('short', 'Admin')).not.toBeNull()
|
||||
expect(validatePassword('Admin', 'Admin')).not.toBeNull()
|
||||
expect(validatePassword('password', 'Admin')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateHostname', () => {
|
||||
it('accepts DNS names and empty', () => {
|
||||
expect(validateHostname('')).toBeNull()
|
||||
expect(validateHostname('admin.mydomain.ext')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects schemes and paths', () => {
|
||||
expect(validateHostname('https://admin.example.com')).not.toBeNull()
|
||||
expect(validateHostname('admin.example.com/path')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
const COMMON_PASSWORDS = new Set([
|
||||
'password',
|
||||
'password123',
|
||||
'password1234',
|
||||
'12345678901234',
|
||||
'qwertyuiopasdf',
|
||||
'admin',
|
||||
'adminadmin',
|
||||
'letmein',
|
||||
'welcome',
|
||||
'welcome123',
|
||||
'changeme',
|
||||
'changeme123',
|
||||
'cluster canvas',
|
||||
'clustercanvas',
|
||||
'correct horse battery staple',
|
||||
])
|
||||
|
||||
const HOSTNAME_PATTERN =
|
||||
/^(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}|localhost)$/i
|
||||
|
||||
export function validatePassword(
|
||||
password: string,
|
||||
username: string,
|
||||
): string | null {
|
||||
if (password.length > 256) {
|
||||
return 'Password must be at most 256 characters'
|
||||
}
|
||||
for (const character of password) {
|
||||
const code = character.charCodeAt(0)
|
||||
if (code < 32 || code === 127) {
|
||||
return 'Password may only contain printable characters'
|
||||
}
|
||||
}
|
||||
if (password.trim().toLowerCase() === username.trim().toLowerCase()) {
|
||||
return 'Password cannot match the login name'
|
||||
}
|
||||
if (COMMON_PASSWORDS.has(password.trim().toLowerCase())) {
|
||||
return 'Password is too common'
|
||||
}
|
||||
const wordCount = password.trim().split(/\s+/).filter(Boolean).length
|
||||
if (password.length >= 14 || wordCount >= 5) {
|
||||
return null
|
||||
}
|
||||
return 'Password must be at least 14 characters or at least 5 words'
|
||||
}
|
||||
|
||||
export function validateHostname(hostname: string): string | null {
|
||||
const trimmed = hostname.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
if (trimmed.includes('/') || trimmed.includes(':') || trimmed.includes(' ')) {
|
||||
return 'Hostname must be a bare DNS name without scheme, port, or path'
|
||||
}
|
||||
if (!HOSTNAME_PATTERN.test(trimmed)) {
|
||||
return 'Hostname is not a valid DNS name'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function validateListenAddress(address: string): string | null {
|
||||
const trimmed = address.trim()
|
||||
if (!trimmed) {
|
||||
return 'Listen address is required'
|
||||
}
|
||||
if (
|
||||
trimmed === '0.0.0.0' ||
|
||||
trimmed === '::' ||
|
||||
trimmed === 'localhost' ||
|
||||
/^\d{1,3}(?:\.\d{1,3}){3}$/.test(trimmed)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return 'Listen address must be an IP address or 0.0.0.0'
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
@@ -0,0 +1,95 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
THEME_STORAGE_KEY,
|
||||
applyResolvedTheme,
|
||||
applyThemeFromPreference,
|
||||
isThemePreference,
|
||||
readThemePreference,
|
||||
resolveTheme,
|
||||
writeThemePreference,
|
||||
} from './theme'
|
||||
|
||||
function createMemoryStorage(initial: Record<string, string> = {}) {
|
||||
const store = new Map(Object.entries(initial))
|
||||
return {
|
||||
getItem(key: string) {
|
||||
return store.has(key) ? store.get(key)! : null
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
store.set(key, value)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('isThemePreference', () => {
|
||||
it('accepts light, dark, and auto', () => {
|
||||
expect(isThemePreference('light')).toBe(true)
|
||||
expect(isThemePreference('dark')).toBe(true)
|
||||
expect(isThemePreference('auto')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects invalid values', () => {
|
||||
expect(isThemePreference('mocha')).toBe(false)
|
||||
expect(isThemePreference('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('readThemePreference', () => {
|
||||
it('defaults to auto when nothing is stored', () => {
|
||||
expect(readThemePreference(createMemoryStorage())).toBe('auto')
|
||||
})
|
||||
|
||||
it('returns a stored preference', () => {
|
||||
const storage = createMemoryStorage({ [THEME_STORAGE_KEY]: 'dark' })
|
||||
expect(readThemePreference(storage)).toBe('dark')
|
||||
})
|
||||
|
||||
it('falls back to auto for invalid stored values', () => {
|
||||
const storage = createMemoryStorage({ [THEME_STORAGE_KEY]: 'neon' })
|
||||
expect(readThemePreference(storage)).toBe('auto')
|
||||
})
|
||||
})
|
||||
|
||||
describe('writeThemePreference', () => {
|
||||
it('persists the preference', () => {
|
||||
const storage = createMemoryStorage()
|
||||
writeThemePreference('light', storage)
|
||||
expect(storage.getItem(THEME_STORAGE_KEY)).toBe('light')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveTheme', () => {
|
||||
it('maps light to latte and dark to mocha', () => {
|
||||
expect(resolveTheme('light', true)).toBe('latte')
|
||||
expect(resolveTheme('dark', false)).toBe('mocha')
|
||||
})
|
||||
|
||||
it('maps auto from the browser preference', () => {
|
||||
expect(resolveTheme('auto', true)).toBe('mocha')
|
||||
expect(resolveTheme('auto', false)).toBe('latte')
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyResolvedTheme', () => {
|
||||
afterEach(() => {
|
||||
delete document.documentElement.dataset.theme
|
||||
})
|
||||
|
||||
it('sets data-theme on the root element', () => {
|
||||
applyResolvedTheme('mocha')
|
||||
expect(document.documentElement.dataset.theme).toBe('mocha')
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyThemeFromPreference', () => {
|
||||
afterEach(() => {
|
||||
delete document.documentElement.dataset.theme
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('applies the resolved theme from preference and system preference', () => {
|
||||
const resolved = applyThemeFromPreference('auto', true)
|
||||
expect(resolved).toBe('mocha')
|
||||
expect(document.documentElement.dataset.theme).toBe('mocha')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
export type ThemePreference = 'light' | 'dark' | 'auto'
|
||||
export type ResolvedTheme = 'latte' | 'mocha'
|
||||
|
||||
export const THEME_STORAGE_KEY = 'clustercanvas.theme'
|
||||
|
||||
const VALID_PREFERENCES: ReadonlySet<string> = new Set([
|
||||
'light',
|
||||
'dark',
|
||||
'auto',
|
||||
])
|
||||
|
||||
export function isThemePreference(value: string): value is ThemePreference {
|
||||
return VALID_PREFERENCES.has(value)
|
||||
}
|
||||
|
||||
export function readThemePreference(
|
||||
storage: Pick<Storage, 'getItem'> = localStorage,
|
||||
): ThemePreference {
|
||||
const storedValue = storage.getItem(THEME_STORAGE_KEY)
|
||||
if (storedValue !== null && isThemePreference(storedValue)) {
|
||||
return storedValue
|
||||
}
|
||||
return 'auto'
|
||||
}
|
||||
|
||||
export function writeThemePreference(
|
||||
preference: ThemePreference,
|
||||
storage: Pick<Storage, 'setItem'> = localStorage,
|
||||
): void {
|
||||
storage.setItem(THEME_STORAGE_KEY, preference)
|
||||
}
|
||||
|
||||
export function resolveTheme(
|
||||
preference: ThemePreference,
|
||||
prefersDark: boolean,
|
||||
): ResolvedTheme {
|
||||
if (preference === 'light') {
|
||||
return 'latte'
|
||||
}
|
||||
if (preference === 'dark') {
|
||||
return 'mocha'
|
||||
}
|
||||
return prefersDark ? 'mocha' : 'latte'
|
||||
}
|
||||
|
||||
export function getSystemPrefersDark(
|
||||
mediaQueryList: Pick<MediaQueryList, 'matches'> = window.matchMedia(
|
||||
'(prefers-color-scheme: dark)',
|
||||
),
|
||||
): boolean {
|
||||
return mediaQueryList.matches
|
||||
}
|
||||
|
||||
export function applyResolvedTheme(
|
||||
resolvedTheme: ResolvedTheme,
|
||||
rootElement: HTMLElement = document.documentElement,
|
||||
): void {
|
||||
rootElement.dataset.theme = resolvedTheme
|
||||
}
|
||||
|
||||
export function applyThemeFromPreference(
|
||||
preference: ThemePreference = readThemePreference(),
|
||||
prefersDark: boolean = getSystemPrefersDark(),
|
||||
rootElement: HTMLElement = document.documentElement,
|
||||
): ResolvedTheme {
|
||||
const resolvedTheme = resolveTheme(preference, prefersDark)
|
||||
applyResolvedTheme(resolvedTheme, rootElement)
|
||||
return resolvedTheme
|
||||
}
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare const __FRONTEND_VERSION__: string
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client", "vitest/globals"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(new URL('./package.json', import.meta.url), 'utf-8'),
|
||||
) as { version: string }
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
define: {
|
||||
__FRONTEND_VERSION__: JSON.stringify(packageJson.version),
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/health': 'http://localhost:8080',
|
||||
'/api': 'http://localhost:8080',
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
globals: true,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user