Add users and network settings tabs with admin protections.

List and delete accounts with last-admin and Administrators-group guards, and expose editable network settings via the configuration UI.
This commit is contained in:
2026-07-18 15:18:01 +02:00
parent f049f766d9
commit 2605c3b346
49 changed files with 5440 additions and 209 deletions
+1
View File
@@ -17,6 +17,7 @@ webui/coverage/
.env .env
.env.* .env.*
!.env.example !.env.example
remotedev.mk
# IDE / OS # IDE / OS
.idea/ .idea/
+47 -1
View File
@@ -1,7 +1,24 @@
.PHONY: service webui dev test build clean .PHONY: service webui dev remotedev test build clean
export PATH := $(HOME)/.local/go/bin:$(PATH) 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
service: service:
cd service && go run ./cmd/server cd service && go run ./cmd/server
@@ -9,12 +26,40 @@ webui:
cd webui && npm run dev cd webui && npm run dev
# Run API (:8080) and web UI (:5173) together; Ctrl+C stops both. # 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: dev:
@trap 'kill 0' EXIT INT TERM; \ @trap 'kill 0' EXIT INT TERM; \
(cd service && go run ./cmd/server) & \ (cd service && go run ./cmd/server) & \
(cd webui && npm run dev) & \ (cd webui && npm run dev) & \
wait 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"
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)"
ssh -t "$(REMOTEDEV_SSH)" "cd $(REMOTEDEV_DIR) && \
pkill -x clustercanvas-server >/dev/null 2>&1 || true; \
pkill -x clustercanvas-webui >/dev/null 2>&1 || true; \
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: test:
cd service && go test ./... cd service && go test ./...
cd webui && npm test cd webui && npm test
@@ -22,6 +67,7 @@ test:
build: build:
mkdir -p bin mkdir -p bin
cd service && go build -o ../bin/clustercanvas-server ./cmd/server 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 cd webui && npm run build
clean: clean:
+57 -7
View File
@@ -1,6 +1,6 @@
# ClusterCanvas # ClusterCanvas
A web-based dashboard for managing Proxmox VMs and LXC containers. After configuration it discovers hosts and shows perinstance details like hostname, storage and RAM usage, and status. From the same interface you can view logs, run updates, deploy changes, and perform other routine maintenance tasks. A web-based dashboard for managing Proxmox VMs, LXC containers, and Docker. After configuration it discovers hosts and shows perinstance details like hostname, storage and RAM usage, and status. From the same interface you can view logs, run updates, deploy changes, and perform other routine maintenance tasks.
## Layout ## Layout
@@ -11,7 +11,7 @@ A web-based dashboard for managing Proxmox VMs and LXC containers. After configu
## Prerequisites ## Prerequisites
- Go 1.22+ - Go 1.22+ (toolchain may download a newer Go as needed)
- Node.js 20+ and npm - Node.js 20+ and npm
## Configuration ## Configuration
@@ -20,8 +20,10 @@ Service config lives under **`/etc/ClusterCanvas/`** by default:
| File | Purpose | | File | Purpose |
|------|---------| |------|---------|
| `settings.json` | Plain base settings (non-secret) | | `settings.json` | Plain base settings (non-secret), including setup/network/security/groups |
| `secrets.enc` | AES-256-GCM encrypted JSON secrets | | `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): **Config directory precedence** (highest first):
@@ -31,13 +33,30 @@ Service config lives under **`/etc/ClusterCanvas/`** by default:
A leading `~/` in the path is expanded to the user home directory. A leading `~/` in the path is expanded to the user home directory.
**Secrets encryption key:** set `CLUSTERCANVAS_CONFIG_KEY` to a base64-encoded 32-byte key: **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 ```bash
openssl rand -base64 32 openssl rand -base64 32
export CLUSTERCANVAS_CONFIG_KEY='…paste output…' 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 ## Run
Start API and web UI together (API `:8080`, UI `:5173`; Ctrl+C stops both): Start API and web UI together (API `:8080`, UI `:5173`; Ctrl+C stops both):
@@ -59,8 +78,39 @@ Custom config directory for the API:
cd service && go run ./cmd/server -configdir="$HOME/.myconfigs" 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. 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). 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 ## Test
```bash ```bash
@@ -75,4 +125,4 @@ This runs `go test ./...` in `service/` and Vitest in `webui/`.
make build make build
``` ```
Produces `bin/clustercanvas-server` and `webui/dist/`. Produces `bin/clustercanvas-server`, `bin/clustercanvas-webui`, and `webui/dist/`.
+12
View File
@@ -0,0 +1,12 @@
# Local remotedev settings (copy to remotedev.mk — that file is gitignored).
# Then run: make remotedev
REMOTEDEV_USER = youruser
REMOTEDEV_HOST = 192.168.1.50
# Optional:
# REMOTEDEV_DIR = clustercanvas-remotedev
# PORT = 8080
# WEBUI_PORT = 5173
# Override the baked-in remotedev encryption key (dev only):
# REMOTEDEV_CONFIG_KEY = $(shell openssl rand -base64 32)
+4 -2
View File
@@ -34,14 +34,16 @@ func main() {
port = "8080" port = "8080"
} }
listenHost := settings.ListenHost(configDir)
listenAddr := listenHost + ":" + port
server := &http.Server{ server := &http.Server{
Addr: ":" + port, Addr: listenAddr,
Handler: api.NewRouter(configDir), Handler: api.NewRouter(configDir),
ReadHeaderTimeout: 5 * time.Second, ReadHeaderTimeout: 5 * time.Second,
} }
go func() { go func() {
log.Printf("clustercanvas service listening on %s", server.Addr) log.Printf("clustercanvas service listening on http://%s", listenAddr)
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("listen: %v", err) log.Fatalf("listen: %v", err)
} }
+73
View File
@@ -0,0 +1,73 @@
package main
import (
"context"
"errors"
"flag"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/websrv"
)
func main() {
webDirFlag := flag.String("webdir", "web", "directory of built SPA assets")
configDirFlag := flag.String(
"configdir",
"",
"config directory for listen address (default /etc/ClusterCanvas; overrides CLUSTERCANVAS_CONFIG_DIR)",
)
flag.Parse()
configDir, err := settings.ResolveDir(*configDirFlag)
if err != nil {
log.Fatalf("config dir: %v", err)
}
port := os.Getenv("WEBUI_PORT")
if port == "" {
port = "5173"
}
apiURL := os.Getenv("API_URL")
if apiURL == "" {
apiURL = "http://127.0.0.1:8080"
}
handler, err := websrv.NewHandler(*webDirFlag, apiURL)
if err != nil {
log.Fatalf("webui handler: %v", err)
}
listenHost := settings.ListenHost(configDir)
listenAddr := listenHost + ":" + port
server := &http.Server{
Addr: listenAddr,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
log.Printf("clustercanvas webui listening on http://%s (webdir=%s; API proxy %s)", listenAddr, *webDirFlag, apiURL)
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("listen: %v", err)
}
}()
stopSignals := make(chan os.Signal, 1)
signal.Notify(stopSignals, syscall.SIGINT, syscall.SIGTERM)
<-stopSignals
shutdownContext, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(shutdownContext); err != nil {
log.Fatalf("shutdown: %v", err)
}
log.Print("webui stopped")
}
+11 -1
View File
@@ -1,3 +1,13 @@
module codeberg.org/SquidSE/ClusterCanvas/service module codeberg.org/SquidSE/ClusterCanvas/service
go 1.22.12 go 1.25.0
require (
github.com/pquerna/otp v1.5.0
golang.org/x/crypto v0.54.0
)
require (
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
golang.org/x/sys v0.47.0 // indirect
)
+15
View File
@@ -0,0 +1,15 @@
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+7 -15
View File
@@ -4,7 +4,6 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"net/http" "net/http"
"os"
"strings" "strings"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings" "codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
@@ -103,6 +102,13 @@ func groupsDeleteHandler(configDir string) http.HandlerFunc {
return return
} }
if name == settings.AdministratorsGroupName {
writeJSON(writer, http.StatusConflict, apiErrorResponse{
Error: "Administrators group cannot be deleted",
})
return
}
settingsPayload, err := loadSettingsOrDefault(configDir) settingsPayload, err := loadSettingsOrDefault(configDir)
if err != nil { if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
@@ -136,20 +142,6 @@ func authorizeGroupsWrite(_ *http.Request) error {
return nil return nil
} }
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{}}, nil
}
return settings.Settings{}, err
}
if payload.Groups == nil {
payload.Groups = []settings.Group{}
}
return payload, nil
}
func validateGroup(group settings.Group) error { func validateGroup(group settings.Group) error {
if strings.TrimSpace(group.Name) == "" { if strings.TrimSpace(group.Name) == "" {
return errors.New("group.name is required") return errors.New("group.name is required")
+64 -20
View File
@@ -18,13 +18,12 @@ type apiErrorResponseTest struct {
Error string `json:"error"` Error string `json:"error"`
} }
func TestGroupsGetEmptyWhenSettingsMissing(t *testing.T) { func TestGroupsGetEmptyWhenNoGroups(t *testing.T) {
t.Parallel()
configDir := t.TempDir() configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir) router := NewRouter(configDir)
request := httptest.NewRequest(http.MethodGet, "/api/v1/groups", nil) request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/groups", nil), cookie)
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request) router.ServeHTTP(recorder, request)
@@ -43,9 +42,8 @@ func TestGroupsGetEmptyWhenSettingsMissing(t *testing.T) {
} }
func TestGroupsUpsertRejectsInvalidPermissions(t *testing.T) { func TestGroupsUpsertRejectsInvalidPermissions(t *testing.T) {
t.Parallel()
configDir := t.TempDir() configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir) router := NewRouter(configDir)
body := []byte(`{ body := []byte(`{
@@ -56,7 +54,7 @@ func TestGroupsUpsertRejectsInvalidPermissions(t *testing.T) {
"permissions": ["nodes.nope"] "permissions": ["nodes.nope"]
} }
}`) }`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/groups", bytes.NewReader(body)) request := withSession(httptest.NewRequest(http.MethodPost, "/api/v1/groups", bytes.NewReader(body)), cookie)
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request) router.ServeHTTP(recorder, request)
@@ -75,9 +73,8 @@ func TestGroupsUpsertRejectsInvalidPermissions(t *testing.T) {
} }
func TestGroupsUpsertUpsertsByName(t *testing.T) { func TestGroupsUpsertUpsertsByName(t *testing.T) {
t.Parallel()
configDir := t.TempDir() configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir) router := NewRouter(configDir)
createBody := []byte(`{ createBody := []byte(`{
@@ -88,7 +85,7 @@ func TestGroupsUpsertUpsertsByName(t *testing.T) {
"permissions": ["nodes.read"] "permissions": ["nodes.read"]
} }
}`) }`)
createReq := httptest.NewRequest(http.MethodPost, "/api/v1/groups", bytes.NewReader(createBody)) createReq := withSession(httptest.NewRequest(http.MethodPost, "/api/v1/groups", bytes.NewReader(createBody)), cookie)
createRec := httptest.NewRecorder() createRec := httptest.NewRecorder()
router.ServeHTTP(createRec, createReq) router.ServeHTTP(createRec, createReq)
if createRec.Code != http.StatusOK { if createRec.Code != http.StatusOK {
@@ -103,14 +100,14 @@ func TestGroupsUpsertUpsertsByName(t *testing.T) {
"permissions": ["nodes.exec","nodes.update"] "permissions": ["nodes.exec","nodes.update"]
} }
}`) }`)
updateReq := httptest.NewRequest(http.MethodPost, "/api/v1/groups", bytes.NewReader(updateBody)) updateReq := withSession(httptest.NewRequest(http.MethodPost, "/api/v1/groups", bytes.NewReader(updateBody)), cookie)
updateRec := httptest.NewRecorder() updateRec := httptest.NewRecorder()
router.ServeHTTP(updateRec, updateReq) router.ServeHTTP(updateRec, updateReq)
if updateRec.Code != http.StatusOK { if updateRec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, updateRec.Code) t.Fatalf("expected status %d, got %d", http.StatusOK, updateRec.Code)
} }
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/groups", nil) getReq := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/groups", nil), cookie)
getRec := httptest.NewRecorder() getRec := httptest.NewRecorder()
router.ServeHTTP(getRec, getReq) router.ServeHTTP(getRec, getReq)
if getRec.Code != http.StatusOK { if getRec.Code != http.StatusOK {
@@ -133,9 +130,8 @@ func TestGroupsUpsertUpsertsByName(t *testing.T) {
} }
func TestGroupsDeleteRemovesByName(t *testing.T) { func TestGroupsDeleteRemovesByName(t *testing.T) {
t.Parallel()
configDir := t.TempDir() configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir) router := NewRouter(configDir)
createBody := []byte(`{ createBody := []byte(`{
@@ -146,21 +142,21 @@ func TestGroupsDeleteRemovesByName(t *testing.T) {
"permissions": ["nodes.read"] "permissions": ["nodes.read"]
} }
}`) }`)
createReq := httptest.NewRequest(http.MethodPost, "/api/v1/groups", bytes.NewReader(createBody)) createReq := withSession(httptest.NewRequest(http.MethodPost, "/api/v1/groups", bytes.NewReader(createBody)), cookie)
createRec := httptest.NewRecorder() createRec := httptest.NewRecorder()
router.ServeHTTP(createRec, createReq) router.ServeHTTP(createRec, createReq)
if createRec.Code != http.StatusOK { if createRec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, createRec.Code) t.Fatalf("expected status %d, got %d", http.StatusOK, createRec.Code)
} }
deleteReq := httptest.NewRequest(http.MethodDelete, "/api/v1/groups?name=Admins", nil) deleteReq := withSession(httptest.NewRequest(http.MethodDelete, "/api/v1/groups?name=Admins", nil), cookie)
deleteRec := httptest.NewRecorder() deleteRec := httptest.NewRecorder()
router.ServeHTTP(deleteRec, deleteReq) router.ServeHTTP(deleteRec, deleteReq)
if deleteRec.Code != http.StatusOK { if deleteRec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, deleteRec.Code) t.Fatalf("expected status %d, got %d", http.StatusOK, deleteRec.Code)
} }
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/groups", nil) getReq := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/groups", nil), cookie)
getRec := httptest.NewRecorder() getRec := httptest.NewRecorder()
router.ServeHTTP(getRec, getReq) router.ServeHTTP(getRec, getReq)
if getRec.Code != http.StatusOK { if getRec.Code != http.StatusOK {
@@ -177,12 +173,11 @@ func TestGroupsDeleteRemovesByName(t *testing.T) {
} }
func TestGroupsDeleteMissingNameFails(t *testing.T) { func TestGroupsDeleteMissingNameFails(t *testing.T) {
t.Parallel()
configDir := t.TempDir() configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir) router := NewRouter(configDir)
deleteReq := httptest.NewRequest(http.MethodDelete, "/api/v1/groups", nil) deleteReq := withSession(httptest.NewRequest(http.MethodDelete, "/api/v1/groups", nil), cookie)
deleteRec := httptest.NewRecorder() deleteRec := httptest.NewRecorder()
router.ServeHTTP(deleteRec, deleteReq) router.ServeHTTP(deleteRec, deleteReq)
@@ -199,3 +194,52 @@ func TestGroupsDeleteMissingNameFails(t *testing.T) {
} }
} }
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)
}
}
+14 -1
View File
@@ -32,7 +32,7 @@ func TestHealthHandler(t *testing.T) {
} }
} }
func TestStatusHandler(t *testing.T) { func TestStatusHandlerRequiresSetup(t *testing.T) {
t.Parallel() t.Parallel()
configDir := t.TempDir() configDir := t.TempDir()
@@ -41,6 +41,19 @@ func TestStatusHandler(t *testing.T) {
NewRouter(configDir).ServeHTTP(recorder, request) 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 { if recorder.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, recorder.Code) t.Fatalf("expected status %d, got %d", http.StatusOK, recorder.Code)
} }
+174
View File
@@ -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, 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
}
}
+100
View File
@@ -0,0 +1,100 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
type networkResponse struct {
Network settings.NetworkSettings `json:"network"`
}
type putNetworkRequest struct {
Network settings.NetworkSettings `json:"network"`
}
func networkGetHandler(configDir string) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
settingsPayload, err := loadSettingsOrDefault(configDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, networkResponse{
Network: settings.EffectiveNetwork(settingsPayload.Network),
})
}
}
func networkPutHandler(configDir string) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
if err := authorizeNetworkWrite(request); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
var payload putNetworkRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
if err := validateNetworkSettings(payload.Network); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
network := settings.EffectiveNetwork(payload.Network)
clientIP := auth.ClientIP(request)
if wouldLockOut, reason := auth.WouldLockOut(clientIP, network); wouldLockOut {
message := "these settings would lock out your current IP"
if reason != "" {
message = message + ": " + reason
}
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: message})
return
}
settingsPayload, err := loadSettingsOrDefault(configDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
settingsPayload.Network = network
if err := settings.SaveSettings(configDir, settingsPayload); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, networkResponse{Network: network})
}
}
func authorizeNetworkWrite(_ *http.Request) error {
// TODO: integrate authz middleware.
return nil
}
func validateNetworkSettings(network settings.NetworkSettings) error {
if err := auth.ValidateListenAddress(network.ListenAddress); err != nil {
return err
}
if err := auth.ValidateHostname(network.PublicHostname); err != nil {
return err
}
if err := auth.ValidateAccessRules(network.Rules); err != nil {
return err
}
switch network.AccessMode {
case settings.AccessModeOpen, settings.AccessModeWhitelist, settings.AccessModeBlacklist:
return nil
default:
return errors.New("network.access_mode must be open, whitelist, or blacklist")
}
}
@@ -0,0 +1,153 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
type networkResponseTest struct {
Network settings.NetworkSettings `json:"network"`
}
func TestNetworkGetReturnsDefaults(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/network", nil), cookie)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String())
}
var payload networkResponseTest
if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Network.ListenAddress != "0.0.0.0" {
t.Fatalf("expected listen_address 0.0.0.0, got %q", payload.Network.ListenAddress)
}
if payload.Network.AccessMode != settings.AccessModeOpen {
t.Fatalf("expected access_mode open, got %q", payload.Network.AccessMode)
}
}
func TestNetworkPutPersistsValidSettings(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
body := []byte(`{
"network": {
"listen_address": "127.0.0.1",
"public_hostname": "",
"access_mode": "open",
"rules": []
}
}`)
request := withSession(httptest.NewRequest(http.MethodPut, "/api/v1/network", bytes.NewReader(body)), cookie)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String())
}
getReq := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/network", nil), cookie)
getRec := httptest.NewRecorder()
router.ServeHTTP(getRec, getReq)
if getRec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, getRec.Code)
}
var payload networkResponseTest
if err := json.NewDecoder(getRec.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Network.ListenAddress != "127.0.0.1" {
t.Fatalf("expected listen_address 127.0.0.1, got %q", payload.Network.ListenAddress)
}
}
func TestNetworkPutRejectsInvalidAccessMode(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
body := []byte(`{
"network": {
"listen_address": "0.0.0.0",
"public_hostname": "",
"access_mode": "maybe",
"rules": []
}
}`)
request := withSession(httptest.NewRequest(http.MethodPut, "/api/v1/network", bytes.NewReader(body)), cookie)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, recorder.Code)
}
}
func TestNetworkPutRejectsLockout(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
body := []byte(`{
"network": {
"listen_address": "0.0.0.0",
"public_hostname": "",
"access_mode": "whitelist",
"rules": ["10.0.0.0/8"]
}
}`)
request := withSession(httptest.NewRequest(http.MethodPut, "/api/v1/network", bytes.NewReader(body)), cookie)
request.RemoteAddr = "192.168.1.50:12345"
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d body=%s", http.StatusBadRequest, recorder.Code, recorder.Body.String())
}
var payload apiErrorResponseTest
if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Error == "" {
t.Fatal("expected lockout error message")
}
}
func TestNetworkPutAllowsWhitelistIncludingClient(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
body := []byte(`{
"network": {
"listen_address": "0.0.0.0",
"public_hostname": "",
"access_mode": "whitelist",
"rules": ["192.168.1.50"]
}
}`)
request := withSession(httptest.NewRequest(http.MethodPut, "/api/v1/network", bytes.NewReader(body)), cookie)
request.RemoteAddr = "192.168.1.50:12345"
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String())
}
}
+23 -15
View File
@@ -6,29 +6,37 @@ import (
const defaultAllowedOrigin = "http://localhost:5173" const defaultAllowedOrigin = "http://localhost:5173"
func withCORS(next http.Handler) http.Handler { // NewRouter builds the HTTP API with setup gating, auth, and network middleware.
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { func NewRouter(configDir string) http.Handler {
writer.Header().Set("Access-Control-Allow-Origin", defaultAllowedOrigin) app, err := newApp(configDir)
writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") if err != nil {
writer.Header().Set("Access-Control-Allow-Headers", "Content-Type") // newApp currently always succeeds; keep signature for future.
panic(err)
if request.Method == http.MethodOptions {
writer.WriteHeader(http.StatusNoContent)
return
} }
next.ServeHTTP(writer, request)
})
}
func NewRouter(configDir string) http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("GET /health", HealthHandler) mux.HandleFunc("GET /health", HealthHandler)
mux.HandleFunc("GET /api/v1/status", StatusHandler) 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("GET /api/v1/groups", groupsGetHandler(configDir)) mux.HandleFunc("GET /api/v1/groups", groupsGetHandler(configDir))
mux.HandleFunc("POST /api/v1/groups", groupsUpsertHandler(configDir)) mux.HandleFunc("POST /api/v1/groups", groupsUpsertHandler(configDir))
mux.HandleFunc("DELETE /api/v1/groups", groupsDeleteHandler(configDir)) mux.HandleFunc("DELETE /api/v1/groups", groupsDeleteHandler(configDir))
mux.HandleFunc("GET /api/v1/security", securityGetHandler(configDir)) mux.HandleFunc("GET /api/v1/security", securityGetHandler(configDir))
mux.HandleFunc("PUT /api/v1/security", securityPutHandler(configDir)) mux.HandleFunc("PUT /api/v1/security", securityPutHandler(configDir))
return withCORS(mux) 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("DELETE /api/v1/users", app.usersDeleteHandler)
return app.withCORS(app.withMiddleware(mux))
} }
+12 -17
View File
@@ -15,12 +15,11 @@ type securityResponseTest struct {
} }
func TestSecurityGetReturnsDefaultsWhenMissing(t *testing.T) { func TestSecurityGetReturnsDefaultsWhenMissing(t *testing.T) {
t.Parallel()
configDir := t.TempDir() configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir) router := NewRouter(configDir)
request := httptest.NewRequest(http.MethodGet, "/api/v1/security", nil) request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/security", nil), cookie)
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request) router.ServeHTTP(recorder, request)
@@ -40,9 +39,8 @@ func TestSecurityGetReturnsDefaultsWhenMissing(t *testing.T) {
} }
func TestSecurityPutRejectsIdleTimeoutZero(t *testing.T) { func TestSecurityPutRejectsIdleTimeoutZero(t *testing.T) {
t.Parallel()
configDir := t.TempDir() configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir) router := NewRouter(configDir)
body := []byte(`{ body := []byte(`{
@@ -53,7 +51,7 @@ func TestSecurityPutRejectsIdleTimeoutZero(t *testing.T) {
"reauth_grace_minutes": 15 "reauth_grace_minutes": 15
} }
}`) }`)
request := httptest.NewRequest(http.MethodPut, "/api/v1/security", bytes.NewReader(body)) request := withSession(httptest.NewRequest(http.MethodPut, "/api/v1/security", bytes.NewReader(body)), cookie)
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request) router.ServeHTTP(recorder, request)
@@ -63,9 +61,8 @@ func TestSecurityPutRejectsIdleTimeoutZero(t *testing.T) {
} }
func TestSecurityPutRejectsIdleTimeoutAboveMax(t *testing.T) { func TestSecurityPutRejectsIdleTimeoutAboveMax(t *testing.T) {
t.Parallel()
configDir := t.TempDir() configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir) router := NewRouter(configDir)
body := []byte(`{ body := []byte(`{
@@ -76,7 +73,7 @@ func TestSecurityPutRejectsIdleTimeoutAboveMax(t *testing.T) {
"reauth_grace_minutes": 15 "reauth_grace_minutes": 15
} }
}`) }`)
request := httptest.NewRequest(http.MethodPut, "/api/v1/security", bytes.NewReader(body)) request := withSession(httptest.NewRequest(http.MethodPut, "/api/v1/security", bytes.NewReader(body)), cookie)
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request) router.ServeHTTP(recorder, request)
@@ -86,9 +83,8 @@ func TestSecurityPutRejectsIdleTimeoutAboveMax(t *testing.T) {
} }
func TestSecurityPutRejectsSessionLifetimeZero(t *testing.T) { func TestSecurityPutRejectsSessionLifetimeZero(t *testing.T) {
t.Parallel()
configDir := t.TempDir() configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir) router := NewRouter(configDir)
body := []byte(`{ body := []byte(`{
@@ -99,7 +95,7 @@ func TestSecurityPutRejectsSessionLifetimeZero(t *testing.T) {
"reauth_grace_minutes": 15 "reauth_grace_minutes": 15
} }
}`) }`)
request := httptest.NewRequest(http.MethodPut, "/api/v1/security", bytes.NewReader(body)) request := withSession(httptest.NewRequest(http.MethodPut, "/api/v1/security", bytes.NewReader(body)), cookie)
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request) router.ServeHTTP(recorder, request)
@@ -109,9 +105,8 @@ func TestSecurityPutRejectsSessionLifetimeZero(t *testing.T) {
} }
func TestSecurityPutRoundTrip(t *testing.T) { func TestSecurityPutRoundTrip(t *testing.T) {
t.Parallel()
configDir := t.TempDir() configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir) router := NewRouter(configDir)
body := []byte(`{ body := []byte(`{
@@ -123,15 +118,15 @@ func TestSecurityPutRoundTrip(t *testing.T) {
"totp_enabled": true "totp_enabled": true
} }
}`) }`)
putReq := httptest.NewRequest(http.MethodPut, "/api/v1/security", bytes.NewReader(body)) putReq := withSession(httptest.NewRequest(http.MethodPut, "/api/v1/security", bytes.NewReader(body)), cookie)
putRec := httptest.NewRecorder() putRec := httptest.NewRecorder()
router.ServeHTTP(putRec, putReq) router.ServeHTTP(putRec, putReq)
if putRec.Code != http.StatusOK { if putRec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, putRec.Code) t.Fatalf("expected status %d, got %d body %s", http.StatusOK, putRec.Code, putRec.Body.String())
} }
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/security", nil) getReq := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/security", nil), cookie)
getRec := httptest.NewRecorder() getRec := httptest.NewRecorder()
router.ServeHTTP(getRec, getReq) router.ServeHTTP(getRec, getReq)
+464
View File
@@ -0,0 +1,464 @@
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"`
}
// 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, meResponse{
UserID: matched.ID,
Username: matched.Username,
Groups: matched.GroupNames,
})
}
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
}
writeJSON(writer, http.StatusOK, meResponse{
UserID: user.ID,
Username: user.Username,
Groups: user.GroupNames,
})
}
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",
"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())
}
}
+71
View File
@@ -0,0 +1,71 @@
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{},
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
}
+145
View File
@@ -0,0 +1,145 @@
package api
import (
"net/http"
"strings"
"time"
"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"`
}
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) usersDeleteHandler(writer http.ResponseWriter, request *http.Request) {
if err := 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
}
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 authorizeUsersWrite(_ *http.Request) error {
// TODO: integrate authz middleware.
return 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
}
+194
View File
@@ -0,0 +1,194 @@
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", nil),
cookie,
)
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)
deleteOperator := withSession(
httptest.NewRequest(http.MethodDelete, "/api/v1/users?id=operator-id", nil),
cookie,
)
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", nil),
cookie,
)
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", nil),
cookie,
)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusNotFound {
t.Fatalf("expected status %d, got %d", http.StatusNotFound, recorder.Code)
}
}
+16
View File
@@ -0,0 +1,16 @@
package auth
import (
"crypto/rand"
"encoding/base64"
"fmt"
)
// NewRandomID returns a URL-safe high-entropy identifier.
func NewRandomID() (string, error) {
buffer := make([]byte, 16)
if _, err := rand.Read(buffer); err != nil {
return "", fmt.Errorf("random id: %w", err)
}
return base64.RawURLEncoding.EncodeToString(buffer), nil
}
+135
View File
@@ -0,0 +1,135 @@
package auth
import (
"fmt"
"net"
"regexp"
"strings"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
var hostnamePattern = regexp.MustCompile(`^(?i)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$|^localhost$`)
// ValidateHostname checks a public hostname (empty is allowed for local/dev).
func ValidateHostname(hostname string) error {
trimmed := strings.TrimSpace(hostname)
if trimmed == "" {
return nil
}
if strings.Contains(trimmed, "/") || strings.Contains(trimmed, ":") || strings.Contains(trimmed, " ") {
return fmt.Errorf("public_hostname must be a bare DNS name without scheme, port, or path")
}
if !hostnamePattern.MatchString(trimmed) {
return fmt.Errorf("public_hostname is not a valid DNS hostname")
}
return nil
}
// HostMatches reports whether requestHost matches the configured public hostname.
// An empty / whitespace public hostname means any host is allowed (local/dev).
func HostMatches(requestHost string, publicHostname string) bool {
wanted := strings.TrimSpace(publicHostname)
if wanted == "" {
return true
}
host := strings.TrimSpace(requestHost)
if parsedHost, _, err := net.SplitHostPort(host); err == nil {
host = parsedHost
}
return strings.EqualFold(host, wanted)
}
// IPAllowed evaluates whitelist/blacklist rules against clientIP.
func IPAllowed(clientIP string, network settings.NetworkSettings) (bool, string) {
network = settings.EffectiveNetwork(network)
parsedIP := net.ParseIP(clientIP)
if parsedIP == nil {
return false, "unable to parse client IP"
}
switch network.AccessMode {
case settings.AccessModeOpen, "":
return true, ""
case settings.AccessModeWhitelist:
if matchesAnyRule(parsedIP, network.Rules) {
return true, ""
}
return false, "client IP is not on the whitelist"
case settings.AccessModeBlacklist:
if matchesAnyRule(parsedIP, network.Rules) {
return false, "client IP matches a blacklist rule"
}
return true, ""
default:
return false, "invalid access mode"
}
}
// WouldLockOut estimates whether rules would block clientIP.
func WouldLockOut(clientIP string, network settings.NetworkSettings) (bool, string) {
allowed, reason := IPAllowed(clientIP, network)
if allowed {
return false, ""
}
return true, reason
}
func matchesAnyRule(ip net.IP, rules []string) bool {
for _, rule := range rules {
trimmed := strings.TrimSpace(rule)
if trimmed == "" {
continue
}
if strings.Contains(trimmed, "/") {
_, network, err := net.ParseCIDR(trimmed)
if err != nil {
continue
}
if network.Contains(ip) {
return true
}
continue
}
ruleIP := net.ParseIP(trimmed)
if ruleIP != nil && ruleIP.Equal(ip) {
return true
}
}
return false
}
// ValidateListenAddress checks a bind address (IP or hostname).
func ValidateListenAddress(address string) error {
trimmed := strings.TrimSpace(address)
if trimmed == "" {
return fmt.Errorf("listen_address is required")
}
if trimmed == "0.0.0.0" || trimmed == "::" || trimmed == "localhost" {
return nil
}
if net.ParseIP(trimmed) != nil {
return nil
}
return fmt.Errorf("listen_address must be an IP address or 0.0.0.0")
}
// ValidateAccessRules validates CIDR/IP entries.
func ValidateAccessRules(rules []string) error {
for _, rule := range rules {
trimmed := strings.TrimSpace(rule)
if trimmed == "" {
continue
}
if strings.Contains(trimmed, "/") {
if _, _, err := net.ParseCIDR(trimmed); err != nil {
return fmt.Errorf("invalid CIDR rule %q", trimmed)
}
continue
}
if net.ParseIP(trimmed) == nil {
return fmt.Errorf("invalid IP rule %q", trimmed)
}
}
return nil
}
+128
View File
@@ -0,0 +1,128 @@
package auth
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"strings"
"unicode"
"golang.org/x/crypto/argon2"
)
const (
argon2Time = 3
argon2Memory = 64 * 1024
argon2Threads = 4
argon2KeyLen = 32
argon2SaltLen = 16
minPasswordLength = 14
minPassphraseWords = 5
maxPasswordLength = 256
)
var (
ErrPasswordTooLong = errors.New("password must be at most 256 characters")
ErrPasswordTooWeak = errors.New("password must be at least 14 characters or at least 5 words")
ErrPasswordMatchesUser = errors.New("password cannot match the login name")
ErrPasswordCommon = errors.New("password is too common")
ErrPasswordNotPrintable = errors.New("password may only contain printable characters")
)
// ValidatePassword checks password policy rules.
func ValidatePassword(password string, username string) error {
if len(password) > maxPasswordLength {
return ErrPasswordTooLong
}
for _, runeValue := range password {
if !unicode.IsPrint(runeValue) {
return ErrPasswordNotPrintable
}
}
if strings.EqualFold(strings.TrimSpace(password), strings.TrimSpace(username)) {
return ErrPasswordMatchesUser
}
if isCommonPassword(password) {
return ErrPasswordCommon
}
wordCount := countWords(password)
if len(password) >= minPasswordLength || wordCount >= minPassphraseWords {
return nil
}
return ErrPasswordTooWeak
}
func countWords(password string) int {
fields := strings.Fields(password)
return len(fields)
}
// HashPassword returns a PHC-formatted argon2id hash string.
func HashPassword(password string) (string, error) {
salt := make([]byte, argon2SaltLen)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("generate salt: %w", err)
}
hash := argon2.IDKey(
[]byte(password),
salt,
argon2Time,
argon2Memory,
argon2Threads,
argon2KeyLen,
)
return fmt.Sprintf(
"$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version,
argon2Memory,
argon2Time,
argon2Threads,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(hash),
), nil
}
// VerifyPassword compares password against a PHC argon2id hash.
func VerifyPassword(password string, encodedHash string) (bool, error) {
parts := strings.Split(encodedHash, "$")
if len(parts) != 6 || parts[1] != "argon2id" {
return false, errors.New("unsupported password hash format")
}
var version int
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
return false, errors.New("invalid hash version")
}
var memory uint32
var timeCost uint32
var threads uint8
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &timeCost, &threads); err != nil {
return false, errors.New("invalid hash parameters")
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false, errors.New("invalid hash salt")
}
expectedHash, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return false, errors.New("invalid hash digest")
}
computed := argon2.IDKey(
[]byte(password),
salt,
timeCost,
memory,
threads,
uint32(len(expectedHash)),
)
return subtle.ConstantTimeCompare(computed, expectedHash) == 1, nil
}
+70
View File
@@ -0,0 +1,70 @@
package auth
import (
"testing"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
func TestValidatePasswordAcceptsLongRandom(t *testing.T) {
t.Parallel()
if err := ValidatePassword("Tr0ub4dor&3-extra!", "Admin"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestValidatePasswordAcceptsPassphrase(t *testing.T) {
t.Parallel()
if err := ValidatePassword("apple banana cherry date elderberry", "Admin"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestValidatePasswordRejectsShort(t *testing.T) {
t.Parallel()
if err := ValidatePassword("short-pass", "Admin"); err == nil {
t.Fatal("expected error")
}
}
func TestValidatePasswordRejectsUsername(t *testing.T) {
t.Parallel()
if err := ValidatePassword("AdminAdminAdmin!", "AdminAdminAdmin!"); err == nil {
t.Fatal("expected error")
}
}
func TestHashAndVerifyPassword(t *testing.T) {
t.Parallel()
hash, err := HashPassword("apple banana cherry date elderberry")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
ok, err := VerifyPassword("apple banana cherry date elderberry", hash)
if err != nil || !ok {
t.Fatalf("VerifyPassword failed: ok=%v err=%v", ok, err)
}
ok, err = VerifyPassword("wrong password here!!", hash)
if err != nil {
t.Fatalf("VerifyPassword error: %v", err)
}
if ok {
t.Fatal("expected mismatch")
}
}
func TestWouldLockOutWhitelist(t *testing.T) {
t.Parallel()
network := settings.NetworkSettings{
AccessMode: settings.AccessModeWhitelist,
Rules: []string{"10.0.0.0/8"},
}
locked, _ := WouldLockOut("192.168.1.5", network)
if !locked {
t.Fatal("expected lock out")
}
locked, _ = WouldLockOut("10.1.2.3", network)
if locked {
t.Fatal("expected allow")
}
}
+250
View File
@@ -0,0 +1,250 @@
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,
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 = &copySession
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
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
}
// 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
}
+47
View File
@@ -0,0 +1,47 @@
package auth
import (
"fmt"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
)
const totpIssuer = "ClusterCanvas"
// GenerateTOTPSecret creates a new TOTP key for username.
func GenerateTOTPSecret(username string) (*otp.Key, error) {
key, err := totp.Generate(totp.GenerateOpts{
Issuer: totpIssuer,
AccountName: username,
Period: 30,
Digits: otp.DigitsSix,
Algorithm: otp.AlgorithmSHA1,
})
if err != nil {
return nil, fmt.Errorf("generate totp: %w", err)
}
return key, nil
}
// VerifyTOTPCode validates a TOTP code against a base32 secret.
func VerifyTOTPCode(secret string, code string) bool {
return totp.Validate(code, secret)
}
// OTPAuthURL builds an otpauth URL from an existing secret.
func OTPAuthURL(username string, secret string) (string, error) {
key, err := otp.NewKeyFromURL(
fmt.Sprintf(
"otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=SHA1&digits=6&period=30",
totpIssuer,
username,
secret,
totpIssuer,
),
)
if err != nil {
return "", err
}
return key.URL(), nil
}
+28
View File
@@ -0,0 +1,28 @@
package auth
import "strings"
// Small denylist of common weak passwords (case-insensitive compare).
var commonPasswords = map[string]struct{}{
"password": {},
"password123": {},
"password1234": {},
"12345678901234": {},
"qwertyuiopasdf": {},
"admin": {},
"adminadmin": {},
"letmein": {},
"welcome": {},
"welcome123": {},
"changeme": {},
"changeme123": {},
"cluster canvas": {},
"clustercanvas": {},
"correct horse battery staple": {},
}
func isCommonPassword(password string) bool {
normalized := strings.ToLower(strings.TrimSpace(password))
_, found := commonPasswords[normalized]
return found
}
+78
View File
@@ -0,0 +1,78 @@
package settings
import (
"encoding/base64"
"encoding/json"
"fmt"
"os"
"path/filepath"
)
type encryptedBlob struct {
Nonce string `json:"nonce"`
Ciphertext string `json:"ciphertext"`
}
func loadEncryptedJSON(dir string, fileName string, key []byte, destination any) error {
path := filepath.Join(dir, fileName)
payload, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read %s: %w", fileName, err)
}
var blob encryptedBlob
if err := json.Unmarshal(payload, &blob); err != nil {
return fmt.Errorf("parse %s envelope: %w", fileName, err)
}
nonce, err := base64.StdEncoding.DecodeString(blob.Nonce)
if err != nil {
return fmt.Errorf("decode nonce: %w", err)
}
ciphertext, err := base64.StdEncoding.DecodeString(blob.Ciphertext)
if err != nil {
return fmt.Errorf("decode ciphertext: %w", err)
}
plaintext, err := open(nonce, ciphertext, key)
if err != nil {
return err
}
if err := json.Unmarshal(plaintext, destination); err != nil {
return fmt.Errorf("parse %s: %w", fileName, err)
}
return nil
}
func saveEncryptedJSON(dir string, fileName string, key []byte, value any) error {
if err := ensureConfigDir(dir); err != nil {
return err
}
plaintext, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("encode %s: %w", fileName, err)
}
nonce, ciphertext, err := seal(plaintext, key)
if err != nil {
return err
}
blob := encryptedBlob{
Nonce: base64.StdEncoding.EncodeToString(nonce),
Ciphertext: base64.StdEncoding.EncodeToString(ciphertext),
}
payload, err := json.MarshalIndent(blob, "", " ")
if err != nil {
return fmt.Errorf("encode %s envelope: %w", fileName, err)
}
payload = append(payload, '\n')
path := filepath.Join(dir, fileName)
if err := os.WriteFile(path, payload, 0o600); err != nil {
return fmt.Errorf("write %s: %w", fileName, err)
}
return nil
}
+28
View File
@@ -0,0 +1,28 @@
package settings
import (
"net"
"path/filepath"
)
// ListenHost returns the configured listen IP for binding, defaulting to 0.0.0.0.
func ListenHost(dir string) string {
payload, err := LoadSettings(dir)
if err != nil {
return "0.0.0.0"
}
network := EffectiveNetwork(payload.Network)
host := network.ListenAddress
if host == "" {
return "0.0.0.0"
}
if host != "0.0.0.0" && host != "::" && host != "localhost" && net.ParseIP(host) == nil {
return "0.0.0.0"
}
return host
}
// SettingsPath returns the path to settings.json in dir.
func SettingsPath(dir string) string {
return filepath.Join(dir, SettingsFileName)
}
+2
View File
@@ -14,6 +14,8 @@ const (
SettingsFileName = "settings.json" SettingsFileName = "settings.json"
SecretsFileName = "secrets.enc" SecretsFileName = "secrets.enc"
PasswordsFileName = "passwords.enc"
SessionsFileName = "sessions.enc"
) )
// ResolveDir returns the config directory using precedence: // ResolveDir returns the config directory using precedence:
+68 -64
View File
@@ -1,18 +1,11 @@
package settings package settings
import ( import (
"encoding/base64" "errors"
"encoding/json"
"fmt" "fmt"
"os" "os"
"path/filepath"
) )
type encryptedBlob struct {
Nonce string `json:"nonce"`
Ciphertext string `json:"ciphertext"`
}
// KeyFromEnv reads and decodes CLUSTERCANVAS_CONFIG_KEY (base64, 16/24/32 bytes). // KeyFromEnv reads and decodes CLUSTERCANVAS_CONFIG_KEY (base64, 16/24/32 bytes).
func KeyFromEnv() ([]byte, error) { func KeyFromEnv() ([]byte, error) {
rawKey := os.Getenv(ConfigKeyEnvVar) rawKey := os.Getenv(ConfigKeyEnvVar)
@@ -24,67 +17,78 @@ func KeyFromEnv() ([]byte, error) {
// LoadSecrets decrypts secrets.enc from dir using key. // LoadSecrets decrypts secrets.enc from dir using key.
func LoadSecrets(dir string, key []byte) (Secrets, error) { func LoadSecrets(dir string, key []byte) (Secrets, error) {
path := filepath.Join(dir, SecretsFileName)
payload, err := os.ReadFile(path)
if err != nil {
return Secrets{}, fmt.Errorf("read secrets: %w", err)
}
var blob encryptedBlob
if err := json.Unmarshal(payload, &blob); err != nil {
return Secrets{}, fmt.Errorf("parse secrets envelope: %w", err)
}
nonce, err := base64.StdEncoding.DecodeString(blob.Nonce)
if err != nil {
return Secrets{}, fmt.Errorf("decode nonce: %w", err)
}
ciphertext, err := base64.StdEncoding.DecodeString(blob.Ciphertext)
if err != nil {
return Secrets{}, fmt.Errorf("decode ciphertext: %w", err)
}
plaintext, err := open(nonce, ciphertext, key)
if err != nil {
return Secrets{}, err
}
var secrets Secrets var secrets Secrets
if err := json.Unmarshal(plaintext, &secrets); err != nil { if err := loadEncryptedJSON(dir, SecretsFileName, key, &secrets); err != nil {
return Secrets{}, fmt.Errorf("parse secrets: %w", err) return Secrets{}, err
} }
return secrets, nil return secrets, nil
} }
// SaveSecrets encrypts secrets and writes secrets.enc to dir with mode 0600. // SaveSecrets encrypts secrets and writes secrets.enc to dir with mode 0600.
func SaveSecrets(dir string, secrets Secrets, key []byte) error { func SaveSecrets(dir string, secrets Secrets, key []byte) error {
if err := ensureConfigDir(dir); err != nil { return saveEncryptedJSON(dir, SecretsFileName, key, secrets)
return err }
}
// LoadPasswords decrypts passwords.enc from dir using key.
plaintext, err := json.Marshal(secrets) func LoadPasswords(dir string, key []byte) (PasswordStore, error) {
if err != nil { var store PasswordStore
return fmt.Errorf("encode secrets: %w", err) if err := loadEncryptedJSON(dir, PasswordsFileName, key, &store); err != nil {
} return PasswordStore{}, err
}
nonce, ciphertext, err := seal(plaintext, key) if store.Users == nil {
if err != nil { store.Users = []UserCredential{}
return err }
} return store, nil
}
blob := encryptedBlob{
Nonce: base64.StdEncoding.EncodeToString(nonce), // LoadPasswordsOrEmpty returns an empty store when passwords.enc is missing.
Ciphertext: base64.StdEncoding.EncodeToString(ciphertext), func LoadPasswordsOrEmpty(dir string, key []byte) (PasswordStore, error) {
} store, err := LoadPasswords(dir, key)
payload, err := json.MarshalIndent(blob, "", " ") if err != nil {
if err != nil { if errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("encode secrets envelope: %w", err) return PasswordStore{Users: []UserCredential{}}, nil
} }
payload = append(payload, '\n') return PasswordStore{}, err
}
path := filepath.Join(dir, SecretsFileName) return store, nil
if err := os.WriteFile(path, payload, 0o600); err != nil { }
return fmt.Errorf("write secrets: %w", err)
} // SavePasswords encrypts the password store and writes passwords.enc.
return nil 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)
} }
+35
View File
@@ -0,0 +1,35 @@
package settings
import (
"errors"
"os"
"strings"
)
// IsSetupCompleted reports whether first-run setup has finished.
func IsSetupCompleted(dir string) (bool, error) {
payload, err := LoadSettings(dir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
return false, err
}
return payload.SetupCompleted, nil
}
// EffectiveNetwork returns network settings with defaults applied.
func EffectiveNetwork(network NetworkSettings) NetworkSettings {
defaults := DefaultNetworkSettings()
if network.ListenAddress == "" {
network.ListenAddress = defaults.ListenAddress
}
network.PublicHostname = strings.TrimSpace(network.PublicHostname)
if network.AccessMode == "" {
network.AccessMode = defaults.AccessMode
}
if network.Rules == nil {
network.Rules = []string{}
}
return network
}
+67
View File
@@ -1,5 +1,7 @@
package settings package settings
import "time"
// GroupScopeKind identifies what a group scopes to. // GroupScopeKind identifies what a group scopes to.
type GroupScopeKind string type GroupScopeKind string
@@ -8,6 +10,10 @@ const (
GroupScopeGroup GroupScopeKind = "group" 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. // Group configures a named permission set for a given scope target.
// //
// Groups are persisted in settings.json and later used by authz middleware. // Groups are persisted in settings.json and later used by authz middleware.
@@ -38,14 +44,75 @@ func DefaultSecuritySettings() SecuritySettings {
} }
} }
// 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. // Settings holds plain (non-secret) base/CLI configuration.
type Settings struct { type Settings struct {
SetupCompleted bool `json:"setup_completed"`
LogLevel string `json:"log_level"` LogLevel string `json:"log_level"`
Groups []Group `json:"groups"` Groups []Group `json:"groups"`
Security SecuritySettings `json:"security"` Security SecuritySettings `json:"security"`
Network NetworkSettings `json:"network"`
} }
// Secrets holds sensitive configuration encrypted at rest. // Secrets holds sensitive configuration encrypted at rest.
type Secrets struct { type Secrets struct {
// Placeholder until Proxmox/API secrets exist. // Placeholder until Proxmox/API secrets exist.
} }
// 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"`
ExpiresAt time.Time `json:"expires_at"`
}
// SessionStore is the plaintext JSON payload inside sessions.enc.
type SessionStore struct {
Sessions []SessionRecord `json:"sessions"`
}
+118
View File
@@ -0,0 +1,118 @@
package websrv
import (
"fmt"
"net"
"net/http"
"net/http/httputil"
"net/url"
"os"
"path"
"path/filepath"
"strings"
)
// NewHandler serves SPA files from webDir and reverse-proxies /api and /health to apiURL.
func NewHandler(webDir string, apiURL string) (http.Handler, error) {
absoluteWebDir, err := filepath.Abs(webDir)
if err != nil {
return nil, fmt.Errorf("resolve webdir: %w", err)
}
info, err := os.Stat(absoluteWebDir)
if err != nil {
return nil, fmt.Errorf("webdir: %w", err)
}
if !info.IsDir() {
return nil, fmt.Errorf("webdir is not a directory: %s", absoluteWebDir)
}
parsedAPIURL, err := url.Parse(apiURL)
if err != nil {
return nil, fmt.Errorf("parse API_URL: %w", err)
}
if parsedAPIURL.Scheme == "" || parsedAPIURL.Host == "" {
return nil, fmt.Errorf("API_URL must include scheme and host: %q", apiURL)
}
apiProxy := httputil.NewSingleHostReverseProxy(parsedAPIURL)
defaultDirector := apiProxy.Director
apiProxy.Director = func(request *http.Request) {
originalHost := request.Host
clientIP := clientIPFromRequest(request)
defaultDirector(request)
// Preserve the browser-facing host so the API can enforce public_hostname.
if request.Header.Get("X-Forwarded-Host") == "" && originalHost != "" {
request.Header.Set("X-Forwarded-Host", originalHost)
}
if request.Header.Get("X-Forwarded-Proto") == "" {
if request.TLS != nil {
request.Header.Set("X-Forwarded-Proto", "https")
} else {
request.Header.Set("X-Forwarded-Proto", "http")
}
}
if clientIP != "" {
prior := strings.TrimSpace(request.Header.Get("X-Forwarded-For"))
if prior == "" {
request.Header.Set("X-Forwarded-For", clientIP)
} else {
request.Header.Set("X-Forwarded-For", prior+", "+clientIP)
}
}
}
fileServer := http.FileServer(http.Dir(absoluteWebDir))
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
requestPath := request.URL.Path
if requestPath == "/health" || strings.HasPrefix(requestPath, "/api/") || requestPath == "/api" {
apiProxy.ServeHTTP(writer, request)
return
}
serveSPA(writer, request, absoluteWebDir, fileServer)
}), nil
}
func clientIPFromRequest(request *http.Request) string {
host, _, err := net.SplitHostPort(request.RemoteAddr)
if err != nil {
return request.RemoteAddr
}
return host
}
func serveSPA(
writer http.ResponseWriter,
request *http.Request,
webDir string,
fileServer http.Handler,
) {
cleanedPath := path.Clean("/" + request.URL.Path)
if cleanedPath == "/" {
http.ServeFile(writer, request, filepath.Join(webDir, "index.html"))
return
}
candidatePath := filepath.Join(webDir, filepath.FromSlash(cleanedPath))
if !isWithinDir(webDir, candidatePath) {
http.NotFound(writer, request)
return
}
fileInfo, err := os.Stat(candidatePath)
if err != nil || fileInfo.IsDir() {
http.ServeFile(writer, request, filepath.Join(webDir, "index.html"))
return
}
fileServer.ServeHTTP(writer, request)
}
func isWithinDir(rootDir string, candidatePath string) bool {
relativePath, err := filepath.Rel(rootDir, candidatePath)
if err != nil {
return false
}
return relativePath != ".." && !strings.HasPrefix(relativePath, ".."+string(os.PathSeparator))
}
+128
View File
@@ -0,0 +1,128 @@
package websrv
import (
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestNewHandlerRejectsMissingWebDir(t *testing.T) {
t.Parallel()
_, err := NewHandler(filepath.Join(t.TempDir(), "missing"), "http://127.0.0.1:8080")
if err == nil {
t.Fatal("expected error for missing webdir")
}
}
func TestNewHandlerRejectsInvalidAPIURL(t *testing.T) {
t.Parallel()
webDir := t.TempDir()
_, err := NewHandler(webDir, "not-a-url")
if err == nil {
t.Fatal("expected error for invalid API_URL")
}
}
func TestSPAServesIndexAndAssets(t *testing.T) {
t.Parallel()
webDir := t.TempDir()
mustWriteFile(t, filepath.Join(webDir, "index.html"), "<html>home</html>")
mustWriteFile(t, filepath.Join(webDir, "assets", "app.js"), "console.log(1)")
api := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(api.Close)
handler, err := NewHandler(webDir, api.URL)
if err != nil {
t.Fatalf("NewHandler: %v", err)
}
indexRecorder := httptest.NewRecorder()
handler.ServeHTTP(indexRecorder, httptest.NewRequest(http.MethodGet, "/", nil))
if indexRecorder.Code != http.StatusOK {
t.Fatalf("GET / status = %d", indexRecorder.Code)
}
if !strings.Contains(indexRecorder.Body.String(), "home") {
t.Fatalf("GET / body = %q", indexRecorder.Body.String())
}
assetRecorder := httptest.NewRecorder()
handler.ServeHTTP(assetRecorder, httptest.NewRequest(http.MethodGet, "/assets/app.js", nil))
if assetRecorder.Code != http.StatusOK {
t.Fatalf("GET /assets/app.js status = %d", assetRecorder.Code)
}
if assetRecorder.Body.String() != "console.log(1)" {
t.Fatalf("asset body = %q", assetRecorder.Body.String())
}
spaRecorder := httptest.NewRecorder()
handler.ServeHTTP(spaRecorder, httptest.NewRequest(http.MethodGet, "/settings", nil))
if spaRecorder.Code != http.StatusOK {
t.Fatalf("GET /settings status = %d", spaRecorder.Code)
}
if !strings.Contains(spaRecorder.Body.String(), "home") {
t.Fatalf("SPA fallback body = %q", spaRecorder.Body.String())
}
}
func TestProxyForwardsAPIAndHealth(t *testing.T) {
t.Parallel()
webDir := t.TempDir()
mustWriteFile(t, filepath.Join(webDir, "index.html"), "<html>home</html>")
var seenPaths []string
var seenForwardedHost string
api := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
seenPaths = append(seenPaths, request.URL.Path)
seenForwardedHost = request.Header.Get("X-Forwarded-Host")
writer.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(writer, `{"ok":true}`)
}))
t.Cleanup(api.Close)
handler, err := NewHandler(webDir, api.URL)
if err != nil {
t.Fatalf("NewHandler: %v", err)
}
healthRecorder := httptest.NewRecorder()
handler.ServeHTTP(healthRecorder, httptest.NewRequest(http.MethodGet, "/health", nil))
if healthRecorder.Code != http.StatusOK {
t.Fatalf("GET /health status = %d", healthRecorder.Code)
}
apiRequest := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
apiRequest.Host = "admin.example.test:5173"
apiRecorder := httptest.NewRecorder()
handler.ServeHTTP(apiRecorder, apiRequest)
if apiRecorder.Code != http.StatusOK {
t.Fatalf("GET /api/v1/status status = %d", apiRecorder.Code)
}
if len(seenPaths) != 2 || seenPaths[0] != "/health" || seenPaths[1] != "/api/v1/status" {
t.Fatalf("proxied paths = %#v", seenPaths)
}
if seenForwardedHost != "admin.example.test:5173" {
t.Fatalf("X-Forwarded-Host = %q, want browser host", seenForwardedHost)
}
}
func mustWriteFile(t *testing.T, path string, contents string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
}
+281 -4
View File
@@ -1,13 +1,14 @@
{ {
"name": "webui", "name": "webui",
"version": "0.0.0", "version": "0.0.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "webui", "name": "webui",
"version": "0.0.0", "version": "0.0.1",
"dependencies": { "dependencies": {
"qrcode": "^1.5.4",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7" "react-dom": "^19.2.7"
}, },
@@ -16,6 +17,7 @@
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/node": "^24.13.2", "@types/node": "^24.13.2",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3", "@vitejs/plugin-react": "^6.0.3",
@@ -1031,6 +1033,15 @@
"undici-types": "~7.18.0" "undici-types": "~7.18.0"
} }
}, },
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.2.17", "version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
@@ -1184,8 +1195,6 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"peer": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@@ -1230,6 +1239,14 @@
"require-from-string": "^2.0.2" "require-from-string": "^2.0.2"
} }
}, },
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"engines": {
"node": ">=6"
}
},
"node_modules/chai": { "node_modules/chai": {
"version": "6.2.2", "version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@@ -1239,6 +1256,32 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"node_modules/convert-source-map": { "node_modules/convert-source-map": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1283,6 +1326,14 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0" "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
} }
}, },
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decimal.js": { "node_modules/decimal.js": {
"version": "10.6.0", "version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
@@ -1307,6 +1358,11 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="
},
"node_modules/dom-accessibility-api": { "node_modules/dom-accessibility-api": {
"version": "0.5.16", "version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
@@ -1314,6 +1370,11 @@
"dev": true, "dev": true,
"peer": true "peer": true
}, },
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
},
"node_modules/entities": { "node_modules/entities": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
@@ -1367,6 +1428,18 @@
} }
} }
}, },
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1381,6 +1454,14 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/html-encoding-sniffer": { "node_modules/html-encoding-sniffer": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
@@ -1402,6 +1483,14 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"engines": {
"node": ">=8"
}
},
"node_modules/is-potential-custom-element-name": { "node_modules/is-potential-custom-element-name": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
@@ -1704,6 +1793,17 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "11.5.2", "version": "11.5.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
@@ -1826,6 +1926,39 @@
} }
} }
}, },
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"engines": {
"node": ">=6"
}
},
"node_modules/parse5": { "node_modules/parse5": {
"version": "8.0.1", "version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
@@ -1838,6 +1971,14 @@
"url": "https://github.com/inikulin/parse5?sponsor=1" "url": "https://github.com/inikulin/parse5?sponsor=1"
} }
}, },
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"engines": {
"node": ">=8"
}
},
"node_modules/pathe": { "node_modules/pathe": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@ -1862,6 +2003,14 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.19", "version": "8.5.19",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz",
@@ -1914,6 +2063,22 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/react": { "node_modules/react": {
"version": "19.2.7", "version": "19.2.7",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
@@ -1953,6 +2118,14 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": { "node_modules/require-from-string": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -1962,6 +2135,11 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="
},
"node_modules/rolldown": { "node_modules/rolldown": {
"version": "1.1.5", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
@@ -2012,6 +2190,11 @@
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==" "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="
}, },
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="
},
"node_modules/siginfo": { "node_modules/siginfo": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
@@ -2039,6 +2222,30 @@
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
"dev": true "dev": true
}, },
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-indent": { "node_modules/strip-indent": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
@@ -2384,6 +2591,11 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0" "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
} }
}, },
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="
},
"node_modules/why-is-node-running": { "node_modules/why-is-node-running": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@@ -2400,6 +2612,33 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/xml-name-validator": { "node_modules/xml-name-validator": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
@@ -2414,6 +2653,44 @@
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true "dev": true
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
} }
} }
} }
+2
View File
@@ -12,6 +12,7 @@
"test:watch": "vitest" "test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"qrcode": "^1.5.4",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7" "react-dom": "^19.2.7"
}, },
@@ -20,6 +21,7 @@
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/node": "^24.13.2", "@types/node": "^24.13.2",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3", "@vitejs/plugin-react": "^6.0.3",
+131
View File
@@ -51,6 +51,15 @@
overflow-y: auto; overflow-y: auto;
} }
.nav-section-divider {
width: 40%;
height: 1px;
margin-block: 0.4rem;
margin-inline: auto;
background: var(--ctp-surface1);
border: none;
}
.sidebar-footer { .sidebar-footer {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -311,6 +320,19 @@
font-weight: 650; font-weight: 650;
color: var(--ctp-text); color: var(--ctp-text);
margin-bottom: 0.1rem; 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 { .groups-li-meta {
@@ -331,6 +353,16 @@
cursor: pointer; cursor: pointer;
} }
.groups-delete:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.groups-delete:disabled:hover {
background: transparent;
color: var(--ctp-text);
}
.groups-delete:hover { .groups-delete:hover {
background: var(--ctp-mauve-hover); background: var(--ctp-mauve-hover);
color: var(--ctp-mauve); color: var(--ctp-mauve);
@@ -468,3 +500,102 @@
padding: 1.5rem; 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;
}
+333 -12
View File
@@ -10,6 +10,24 @@ function stubFetchOk() {
vi.fn().mockImplementation((input: RequestInfo | URL) => { vi.fn().mockImplementation((input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString() const url = typeof input === 'string' ? input : input.toString()
if (url.includes('/api/v1/setup/status')) {
return Promise.resolve({
ok: true,
json: async () => ({ completed: true, client_ip: '127.0.0.1' }),
})
}
if (url.includes('/api/v1/auth/me')) {
return Promise.resolve({
ok: true,
json: async () => ({
user_id: 'u1',
username: 'Admin',
groups: ['Administrators'],
}),
})
}
if (url.includes('/health')) { if (url.includes('/health')) {
return Promise.resolve({ return Promise.resolve({
ok: true, ok: true,
@@ -49,6 +67,52 @@ function stubFetchOk() {
}) })
} }
if (url.includes('/api/v1/users')) {
return Promise.resolve({
ok: true,
json: async () => ({
users: [
{
id: 'u1',
username: 'Admin',
enabled: true,
totp_confirmed: false,
group_names: ['Administrators'],
created_at: '2026-01-01T00:00:00.000Z',
password_changed_at: '2026-01-01T00:00:00.000Z',
is_admin: true,
can_delete: false,
},
{
id: 'u2',
username: 'operator',
enabled: true,
totp_confirmed: true,
group_names: ['Operators'],
created_at: '2026-02-01T00:00:00.000Z',
password_changed_at: '2026-02-01T00:00:00.000Z',
is_admin: false,
can_delete: true,
},
],
}),
})
}
if (url.includes('/api/v1/network')) {
return Promise.resolve({
ok: true,
json: async () => ({
network: {
listen_address: '0.0.0.0',
public_hostname: '',
access_mode: 'open',
rules: [],
},
}),
})
}
return Promise.resolve({ return Promise.resolve({
ok: true, ok: true,
json: async () => ({}), json: async () => ({}),
@@ -95,7 +159,7 @@ describe('App', () => {
render(<App />) render(<App />)
expect( expect(
screen.getByRole('heading', { name: 'Cluster Canvas' }), await screen.findByRole('heading', { name: 'Cluster Canvas' }),
).toBeInTheDocument() ).toBeInTheDocument()
expect( expect(
screen.getByText(`Frontend Version ${__FRONTEND_VERSION__}`), screen.getByText(`Frontend Version ${__FRONTEND_VERSION__}`),
@@ -103,14 +167,14 @@ describe('App', () => {
expect(await screen.findByText('Backend Version 0.1.0')).toBeInTheDocument() expect(await screen.findByText('Backend Version 0.1.0')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Overview' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Overview' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'VMs' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Virtual Machines' })).toBeInTheDocument()
expect( expect(
screen.getByRole('button', { name: 'Containers' }), screen.getByRole('button', { name: 'Containers' }),
).toBeInTheDocument() ).toBeInTheDocument()
expect( expect(
screen.getByRole('button', { name: 'Configuration' }), screen.getByRole('button', { name: 'Configuration' }),
).toBeInTheDocument() ).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Profile' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Admin' })).toBeInTheDocument()
expect( expect(
screen.getByRole('heading', { name: 'Overview', level: 2 }), screen.getByRole('heading', { name: 'Overview', level: 2 }),
@@ -124,10 +188,12 @@ describe('App', () => {
render(<App />) render(<App />)
await user.click(screen.getByRole('button', { name: 'Profile' })) expect(await screen.findByRole('heading', { name: 'Cluster Canvas' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Admin' }))
expect( expect(
screen.getByRole('heading', { name: 'Profile', level: 2 }), screen.getByRole('heading', { name: 'Admin', level: 2 }),
).toBeInTheDocument() ).toBeInTheDocument()
expect(window.location.pathname).toBe('/profile') expect(window.location.pathname).toBe('/profile')
expect( expect(
@@ -142,9 +208,9 @@ describe('App', () => {
render(<App />) render(<App />)
expect( expect(
screen.getByRole('heading', { name: 'Profile', level: 2 }), await screen.findByRole('heading', { name: 'Admin', level: 2 }),
).toBeInTheDocument() ).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Profile' })).toHaveAttribute( expect(screen.getByRole('button', { name: 'Admin' })).toHaveAttribute(
'aria-current', 'aria-current',
'page', 'page',
) )
@@ -156,12 +222,20 @@ describe('App', () => {
render(<App />) render(<App />)
await user.click(screen.getByRole('button', { name: 'VMs' })) expect(await screen.findByRole('heading', { name: 'Cluster Canvas' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Virtual Machines' }))
expect( expect(
screen.getByRole('heading', { name: 'VMs', level: 2 }), screen.getByRole('heading', { name: 'Virtual Machines', level: 2 }),
).toBeInTheDocument() ).toBeInTheDocument()
expect(window.location.pathname).toBe('/vms') expect(window.location.pathname).toBe('/vms')
await user.click(screen.getByRole('button', { name: 'Docker' }))
expect(
screen.getByRole('heading', { name: 'Docker', level: 2 }),
).toBeInTheDocument()
expect(window.location.pathname).toBe('/docker')
await user.click(screen.getByRole('button', { name: 'Configuration' })) await user.click(screen.getByRole('button', { name: 'Configuration' }))
expect( expect(
screen.getByRole('heading', { name: 'Configuration', level: 2 }), screen.getByRole('heading', { name: 'Configuration', level: 2 }),
@@ -180,6 +254,8 @@ describe('App', () => {
render(<App />) render(<App />)
expect(await screen.findByRole('heading', { name: 'Cluster Canvas' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Configuration' })) await user.click(screen.getByRole('button', { name: 'Configuration' }))
await user.click(screen.getByRole('tab', { name: 'Groups' })) await user.click(screen.getByRole('tab', { name: 'Groups' }))
@@ -197,7 +273,7 @@ describe('App', () => {
render(<App />) render(<App />)
expect( expect(
screen.getByRole('heading', { name: 'Configuration', level: 2 }), await screen.findByRole('heading', { name: 'Configuration', level: 2 }),
).toBeInTheDocument() ).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Groups' })).toHaveAttribute( expect(screen.getByRole('tab', { name: 'Groups' })).toHaveAttribute(
'aria-selected', 'aria-selected',
@@ -212,6 +288,8 @@ describe('App', () => {
render(<App />) render(<App />)
expect(await screen.findByRole('heading', { name: 'Cluster Canvas' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Configuration' })) await user.click(screen.getByRole('button', { name: 'Configuration' }))
for (const label of [ for (const label of [
@@ -277,6 +355,29 @@ describe('App', () => {
expect(screen.getByLabelText('Re-auth grace period (minutes)')).toBeDisabled() expect(screen.getByLabelText('Re-auth grace period (minutes)')).toBeDisabled()
expect(screen.queryByLabelText('Theme')).not.toBeInTheDocument() expect(screen.queryByLabelText('Theme')).not.toBeInTheDocument()
await user.click(screen.getByRole('tab', { name: 'Users' }))
expect(screen.getByRole('tab', { name: 'Users' })).toHaveAttribute(
'aria-selected',
'true',
)
expect(await screen.findByText('operator')).toBeInTheDocument()
expect(screen.getByText('Admin', { selector: '.user-badge' })).toBeInTheDocument()
expect(screen.getByText('Configured accounts')).toBeInTheDocument()
const deleteButtons = screen.getAllByRole('button', { name: 'Delete' })
expect(deleteButtons[0]).toBeDisabled()
expect(deleteButtons[1]).toBeEnabled()
await user.click(screen.getByRole('tab', { name: 'Network' }))
expect(screen.getByRole('tab', { name: 'Network' })).toHaveAttribute(
'aria-selected',
'true',
)
expect(await screen.findByLabelText('Listen address')).toHaveValue('0.0.0.0')
expect(screen.getByLabelText('Access mode')).toHaveValue('open')
expect(
screen.getByRole('button', { name: 'Save network settings' }),
).toBeInTheDocument()
await user.click(screen.getByRole('tab', { name: 'Integrations' })) await user.click(screen.getByRole('tab', { name: 'Integrations' }))
expect(screen.getByRole('tab', { name: 'Integrations' })).toHaveAttribute( expect(screen.getByRole('tab', { name: 'Integrations' })).toHaveAttribute(
'aria-selected', 'aria-selected',
@@ -285,12 +386,202 @@ describe('App', () => {
expect(screen.getByText('Coming soon')).toBeInTheDocument() expect(screen.getByText('Coming soon')).toBeInTheDocument()
}) })
it('deletes a deletable user from the Users tab', async () => {
const user = userEvent.setup()
const fetchMock = vi.fn().mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.toString()
if (url.includes('/api/v1/setup/status')) {
return Promise.resolve({
ok: true,
json: async () => ({ completed: true, client_ip: '127.0.0.1' }),
})
}
if (url.includes('/api/v1/auth/me')) {
return Promise.resolve({
ok: true,
json: async () => ({
user_id: 'u1',
username: 'Admin',
groups: ['Administrators'],
}),
})
}
if (url.includes('/health')) {
return Promise.resolve({
ok: true,
json: async () => ({ status: 'ok' }),
})
}
if (url.includes('/api/v1/status')) {
return Promise.resolve({
ok: true,
json: async () => ({ service: 'clustercanvas', version: '0.1.0' }),
})
}
if (url.includes('/api/v1/users') && init?.method === 'DELETE') {
return Promise.resolve({
ok: true,
json: async () => ({
users: [
{
id: 'u1',
username: 'Admin',
enabled: true,
totp_confirmed: false,
group_names: ['Administrators'],
created_at: '2026-01-01T00:00:00.000Z',
password_changed_at: '2026-01-01T00:00:00.000Z',
is_admin: true,
can_delete: false,
},
],
}),
})
}
if (url.includes('/api/v1/users')) {
return Promise.resolve({
ok: true,
json: async () => ({
users: [
{
id: 'u1',
username: 'Admin',
enabled: true,
totp_confirmed: false,
group_names: ['Administrators'],
created_at: '2026-01-01T00:00:00.000Z',
password_changed_at: '2026-01-01T00:00:00.000Z',
is_admin: true,
can_delete: false,
},
{
id: 'u2',
username: 'operator',
enabled: true,
totp_confirmed: true,
group_names: ['Operators'],
created_at: '2026-02-01T00:00:00.000Z',
password_changed_at: '2026-02-01T00:00:00.000Z',
is_admin: false,
can_delete: true,
},
],
}),
})
}
return Promise.resolve({ ok: true, json: async () => ({}) })
})
vi.stubGlobal('fetch', fetchMock)
render(<App />)
expect(await screen.findByRole('heading', { name: 'Cluster Canvas' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Configuration' }))
await user.click(screen.getByRole('tab', { name: 'Users' }))
expect(await screen.findByText('operator')).toBeInTheDocument()
const deleteButtons = screen.getAllByRole('button', { name: 'Delete' })
await user.click(deleteButtons[1])
expect(await screen.findByText('Configured accounts')).toBeInTheDocument()
expect(screen.queryByText('operator')).not.toBeInTheDocument()
expect(screen.getByText('Admin', { selector: '.user-badge' })).toBeInTheDocument()
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('/api/v1/users?id=u2'),
expect.objectContaining({ method: 'DELETE' }),
)
})
it('saves network settings from the Network tab', async () => {
const user = userEvent.setup()
const fetchMock = vi.fn().mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.toString()
if (url.includes('/api/v1/setup/status')) {
return Promise.resolve({
ok: true,
json: async () => ({ completed: true, client_ip: '127.0.0.1' }),
})
}
if (url.includes('/api/v1/auth/me')) {
return Promise.resolve({
ok: true,
json: async () => ({
user_id: 'u1',
username: 'Admin',
groups: ['Administrators'],
}),
})
}
if (url.includes('/health')) {
return Promise.resolve({
ok: true,
json: async () => ({ status: 'ok' }),
})
}
if (url.includes('/api/v1/status')) {
return Promise.resolve({
ok: true,
json: async () => ({ service: 'clustercanvas', version: '0.1.0' }),
})
}
if (url.includes('/api/v1/network') && init?.method === 'PUT') {
return Promise.resolve({
ok: true,
json: async () => ({
network: {
listen_address: '127.0.0.1',
public_hostname: '',
access_mode: 'open',
rules: [],
},
}),
})
}
if (url.includes('/api/v1/network')) {
return Promise.resolve({
ok: true,
json: async () => ({
network: {
listen_address: '0.0.0.0',
public_hostname: '',
access_mode: 'open',
rules: [],
},
}),
})
}
return Promise.resolve({ ok: true, json: async () => ({}) })
})
vi.stubGlobal('fetch', fetchMock)
render(<App />)
expect(await screen.findByRole('heading', { name: 'Cluster Canvas' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Configuration' }))
await user.click(screen.getByRole('tab', { name: 'Network' }))
const listenInput = await screen.findByLabelText('Listen address')
await user.clear(listenInput)
await user.type(listenInput, '127.0.0.1')
await user.click(screen.getByRole('button', { name: 'Save network settings' }))
expect(await screen.findByText('Network settings saved.')).toBeInTheDocument()
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('/api/v1/network'),
expect.objectContaining({ method: 'PUT' }),
)
})
it('rejects invalid idle timeout on Security save', async () => { it('rejects invalid idle timeout on Security save', async () => {
const user = userEvent.setup() const user = userEvent.setup()
stubFetchOk() stubFetchOk()
render(<App />) render(<App />)
expect(await screen.findByRole('heading', { name: 'Cluster Canvas' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Configuration' })) await user.click(screen.getByRole('button', { name: 'Configuration' }))
await user.click(screen.getByRole('tab', { name: 'Security' })) await user.click(screen.getByRole('tab', { name: 'Security' }))
@@ -311,9 +602,29 @@ describe('App', () => {
it('shows unavailable when backend status fetch fails', async () => { it('shows unavailable when backend status fetch fails', async () => {
vi.stubGlobal( vi.stubGlobal(
'fetch', 'fetch',
vi.fn().mockResolvedValue({ vi.fn().mockImplementation((input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString()
if (url.includes('/api/v1/setup/status')) {
return Promise.resolve({
ok: true,
json: async () => ({ completed: true, client_ip: '127.0.0.1' }),
})
}
if (url.includes('/api/v1/auth/me')) {
return Promise.resolve({
ok: true,
json: async () => ({
user_id: 'u1',
username: 'Admin',
groups: ['Administrators'],
}),
})
}
return Promise.resolve({
ok: false, ok: false,
status: 503, status: 503,
json: async () => ({}),
})
}), }),
) )
@@ -324,12 +635,15 @@ describe('App', () => {
).toBeInTheDocument() ).toBeInTheDocument()
}) })
it('defaults to latte when preference is auto and browser is light', () => { it('defaults to latte when preference is auto and browser is light', async () => {
stubFetchOk() stubFetchOk()
stubMatchMedia(false) stubMatchMedia(false)
render(<App />) render(<App />)
expect(
await screen.findByRole('heading', { name: 'Cluster Canvas' }),
).toBeInTheDocument()
expect(document.documentElement.dataset.theme).toBe('latte') expect(document.documentElement.dataset.theme).toBe('latte')
}) })
@@ -340,6 +654,8 @@ describe('App', () => {
render(<App />) render(<App />)
expect(await screen.findByRole('heading', { name: 'Cluster Canvas' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Configuration' })) await user.click(screen.getByRole('button', { name: 'Configuration' }))
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
@@ -368,6 +684,9 @@ describe('App', () => {
render(<App />) render(<App />)
expect(
await screen.findByRole('heading', { name: 'Cluster Canvas' }),
).toBeInTheDocument()
expect(document.documentElement.dataset.theme).toBe('mocha') expect(document.documentElement.dataset.theme).toBe('mocha')
await user.click(screen.getByRole('button', { name: 'Configuration' })) await user.click(screen.getByRole('button', { name: 'Configuration' }))
@@ -394,6 +713,8 @@ describe('App', () => {
render(<App />) render(<App />)
expect(await screen.findByRole('heading', { name: 'Cluster Canvas' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Configuration' })) await user.click(screen.getByRole('button', { name: 'Configuration' }))
await user.selectOptions(screen.getByLabelText('Theme'), 'light') await user.selectOptions(screen.getByLabelText('Theme'), 'light')
await user.click(screen.getByRole('button', { name: 'Save' })) await user.click(screen.getByRole('button', { name: 'Save' }))
+468 -6
View File
@@ -1,16 +1,27 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { import {
deleteGroup, deleteGroup,
deleteUser,
DEFAULT_NETWORK_SETTINGS,
DEFAULT_SECURITY_SETTINGS, DEFAULT_SECURITY_SETTINGS,
fetchGroups, fetchGroups,
fetchMe,
fetchNetwork,
fetchSecurity, fetchSecurity,
fetchSetupStatus,
fetchStatus, fetchStatus,
fetchUsers,
type AccessMode,
type Group, type Group,
type GroupScopeKind, type GroupScopeKind,
type NetworkSettings,
type SecuritySettings, type SecuritySettings,
type User,
saveNetwork,
saveSecurity, saveSecurity,
upsertGroup, upsertGroup,
} from './api/client' } from './api/client'
import { LoginPage } from './LoginPage'
import { import {
navigateTo, navigateTo,
parseLocation, parseLocation,
@@ -19,6 +30,11 @@ import {
type ConfigTabId, type ConfigTabId,
type SectionId, type SectionId,
} from './navigation' } from './navigation'
import {
validateHostname,
validateListenAddress,
} from './passwordPolicy'
import { SetupWizard } from './SetupWizard'
import { import {
applyThemeFromPreference, applyThemeFromPreference,
getSystemPrefersDark, getSystemPrefersDark,
@@ -30,16 +46,20 @@ import './App.css'
const NAV_ITEMS: ReadonlyArray<{ id: SectionId; label: string }> = [ const NAV_ITEMS: ReadonlyArray<{ id: SectionId; label: string }> = [
{ id: 'overview', label: 'Overview' }, { id: 'overview', label: 'Overview' },
{ id: 'vms', label: 'VMs' },
{ id: 'containers', label: 'Containers' }, { id: 'containers', label: 'Containers' },
{ id: 'docker', label: 'Docker' },
{ id: 'vms', label: 'Virtual Machines' },
] ]
const SECTION_TITLES: Record<SectionId, string> = { const SECTION_TITLES: Record<SectionId, string> = {
overview: 'Overview', overview: 'Overview',
vms: 'VMs', vms: 'Virtual Machines',
docker: 'Docker',
containers: 'Containers', containers: 'Containers',
profile: 'Profile', profile: 'Profile',
configuration: 'Configuration', configuration: 'Configuration',
setup: 'Setup',
login: 'Sign in',
} }
const CONFIG_TABS: ReadonlyArray<{ id: ConfigTabId; label: string }> = [ const CONFIG_TABS: ReadonlyArray<{ id: ConfigTabId; label: string }> = [
@@ -64,6 +84,16 @@ const DEFAULT_PERMISSIONS = [
'roles.manage', 'roles.manage',
] as const ] as const
const ADMINISTRATORS_GROUP_NAME = 'Administrators'
function formatUserTimestamp(value: string): string {
const parsed = Date.parse(value)
if (Number.isNaN(parsed)) {
return value || '—'
}
return new Date(parsed).toLocaleString()
}
function CogIcon() { function CogIcon() {
return ( return (
<svg <svg
@@ -292,6 +322,12 @@ function GroupsConfigTab() {
<button <button
type="button" type="button"
className="groups-delete" className="groups-delete"
disabled={group.name === ADMINISTRATORS_GROUP_NAME}
title={
group.name === ADMINISTRATORS_GROUP_NAME
? 'Administrators group cannot be deleted'
: undefined
}
onClick={() => void handleDeleteGroup(group.name)} onClick={() => void handleDeleteGroup(group.name)}
> >
Delete Delete
@@ -380,6 +416,312 @@ function GroupsConfigTab() {
) )
} }
function UsersConfigTab() {
const [users, setUsers] = useState<ReadonlyArray<User>>([])
const [isLoadingUsers, setIsLoadingUsers] = useState(true)
const [usersLoadError, setUsersLoadError] = useState<string | null>(null)
const [actionError, setActionError] = useState<string | null>(null)
useEffect(() => {
let isCancelled = false
async function loadUsers() {
setIsLoadingUsers(true)
setUsersLoadError(null)
try {
const response = await fetchUsers()
if (!isCancelled) {
setUsers(response.users)
}
} catch (err) {
if (!isCancelled) {
setUsersLoadError(
err instanceof Error ? err.message : 'Failed to load users',
)
}
} finally {
if (!isCancelled) {
setIsLoadingUsers(false)
}
}
}
void loadUsers()
return () => {
isCancelled = true
}
}, [])
async function handleDeleteUser(userId: string) {
setActionError(null)
try {
const response = await deleteUser(userId)
setUsers(response.users)
} catch (err) {
setActionError(
err instanceof Error ? err.message : 'Failed to delete user',
)
}
}
return (
<div className="groups-panel users-panel">
<div className="groups-list">
<p className="config-hint">Configured accounts</p>
{actionError ? (
<p className="config-empty groups-error" role="alert">
{actionError}
</p>
) : null}
{isLoadingUsers ? (
<p className="config-empty">Loading</p>
) : usersLoadError ? (
<p className="config-empty" role="alert">
{usersLoadError}
</p>
) : users.length === 0 ? (
<p className="config-empty">No users configured yet.</p>
) : (
<ul className="groups-ul">
{users.map((user) => (
<li key={user.id} className="groups-li">
<div className="groups-li-content">
<div className="groups-li-title">
{user.username}
{user.is_admin ? (
<span className="user-badge">Admin</span>
) : null}
</div>
<div className="groups-li-meta">
Groups:{' '}
{user.group_names.length > 0
? user.group_names.join(', ')
: '—'}
</div>
<div className="groups-li-meta">
Enabled: {user.enabled ? 'yes' : 'no'} · TOTP:{' '}
{user.totp_confirmed ? 'confirmed' : 'not confirmed'}
</div>
<div className="groups-li-meta">
Created: {formatUserTimestamp(user.created_at)} · Password
changed: {formatUserTimestamp(user.password_changed_at)}
</div>
<div className="groups-li-meta">ID: {user.id}</div>
</div>
<button
type="button"
className="groups-delete"
disabled={!user.can_delete}
title={
user.can_delete
? undefined
: 'Cannot delete the last administrator'
}
onClick={() => void handleDeleteUser(user.id)}
>
Delete
</button>
</li>
))}
</ul>
)}
</div>
</div>
)
}
function NetworkConfigTab() {
const [draft, setDraft] = useState<NetworkSettings>(DEFAULT_NETWORK_SETTINGS)
const [rulesText, setRulesText] = useState('')
const [isLoading, setIsLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null)
const [submitError, setSubmitError] = useState<string | null>(null)
const [saveSucceeded, setSaveSucceeded] = useState(false)
useEffect(() => {
let isCancelled = false
async function loadNetwork() {
setIsLoading(true)
setLoadError(null)
try {
const response = await fetchNetwork()
if (!isCancelled) {
setDraft(response.network)
setRulesText(response.network.rules.join('\n'))
}
} catch (err) {
if (!isCancelled) {
setLoadError(
err instanceof Error
? err.message
: 'Failed to load network settings',
)
}
} finally {
if (!isCancelled) {
setIsLoading(false)
}
}
}
void loadNetwork()
return () => {
isCancelled = true
}
}, [])
async function handleSave() {
setSubmitError(null)
setSaveSucceeded(false)
const listenError = validateListenAddress(draft.listen_address)
if (listenError) {
setSubmitError(listenError)
return
}
const hostnameError = validateHostname(draft.public_hostname)
if (hostnameError) {
setSubmitError(hostnameError)
return
}
const rules = rulesText
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
try {
const response = await saveNetwork({ ...draft, rules })
setDraft(response.network)
setRulesText(response.network.rules.join('\n'))
setSaveSucceeded(true)
} catch (err) {
setSubmitError(
err instanceof Error ? err.message : 'Failed to save network settings',
)
}
}
if (isLoading) {
return <p className="config-empty">Loading</p>
}
if (loadError) {
return (
<p className="config-empty" role="alert">
{loadError}
</p>
)
}
return (
<div className="security-panel network-panel">
<p className="config-hint">
Listen bind address and IP access control for the admin UI.
</p>
{submitError ? (
<p className="config-empty groups-error" role="alert">
{submitError}
</p>
) : null}
{saveSucceeded ? (
<p className="config-hint" role="status">
Network settings saved.
</p>
) : null}
<div className="config-field">
<label htmlFor="network-listen">Listen address</label>
<input
id="network-listen"
className="config-input"
value={draft.listen_address}
onChange={(event) => {
setDraft((previous) => ({
...previous,
listen_address: event.target.value,
}))
setSaveSucceeded(false)
}}
/>
<p className="config-hint">
Default 0.0.0.0 (all interfaces). Applied on next process restart.
</p>
</div>
<div className="config-field">
<label htmlFor="network-hostname">Public hostname</label>
<input
id="network-hostname"
className="config-input"
placeholder="admin.mydomain.ext"
value={draft.public_hostname}
onChange={(event) => {
setDraft((previous) => ({
...previous,
public_hostname: event.target.value,
}))
setSaveSucceeded(false)
}}
/>
<p className="config-hint">
Leave empty for local access by IP. When set, the UI must be visited
via that exact hostname.
</p>
</div>
<div className="config-field">
<label htmlFor="network-access-mode">Access mode</label>
<select
id="network-access-mode"
className="config-input"
value={draft.access_mode}
onChange={(event) => {
setDraft((previous) => ({
...previous,
access_mode: event.target.value as AccessMode,
}))
setSaveSucceeded(false)
}}
>
<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>
{draft.access_mode !== 'open' ? (
<div className="config-field">
<label htmlFor="network-rules">IP / CIDR rules (one per line)</label>
<textarea
id="network-rules"
className="config-input wizard-rules"
rows={5}
value={rulesText}
onChange={(event) => {
setRulesText(event.target.value)
setSaveSucceeded(false)
}}
/>
</div>
) : null}
<button
type="button"
className="groups-save"
onClick={() => void handleSave()}
>
Save network settings
</button>
</div>
)
}
function validateSecurityDraft(security: SecuritySettings): string | null { function validateSecurityDraft(security: SecuritySettings): string | null {
if ( if (
!Number.isInteger(security.idle_timeout_minutes) || !Number.isInteger(security.idle_timeout_minutes) ||
@@ -672,10 +1014,18 @@ function ConfigTabPanelContent({
return <GroupsConfigTab /> return <GroupsConfigTab />
} }
if (activeConfigTab === 'users') {
return <UsersConfigTab />
}
if (activeConfigTab === 'security') { if (activeConfigTab === 'security') {
return <SecurityConfigTab /> return <SecurityConfigTab />
} }
if (activeConfigTab === 'network') {
return <NetworkConfigTab />
}
return <p className="config-empty">Coming soon</p> return <p className="config-empty">Coming soon</p>
} }
@@ -759,6 +1109,12 @@ function ConfigurationPanel({
) )
} }
type BootState =
| { kind: 'loading' }
| { kind: 'setup'; clientIp: string }
| { kind: 'login' }
| { kind: 'ready'; username: string }
function App() { function App() {
const initialLocation = parseLocation(window.location.pathname) const initialLocation = parseLocation(window.location.pathname)
const [activeSection, setActiveSection] = useState<SectionId>( const [activeSection, setActiveSection] = useState<SectionId>(
@@ -767,9 +1123,11 @@ function App() {
const [activeConfigTab, setActiveConfigTab] = useState<ConfigTabId>( const [activeConfigTab, setActiveConfigTab] = useState<ConfigTabId>(
initialLocation.configTab, initialLocation.configTab,
) )
// Stub until auth provides the logged-in display name. const [profileDisplayName, setProfileDisplayName] = useState<string | null>(
const [profileDisplayName] = useState<string | null>(null) null,
)
const [backendVersion, setBackendVersion] = useState('…') const [backendVersion, setBackendVersion] = useState('…')
const [bootState, setBootState] = useState<BootState>({ kind: 'loading' })
const { themePreference, updateThemePreference } = useThemePreference() const { themePreference, updateThemePreference } = useThemePreference()
const profileLabel = profileDisplayName ?? 'Profile' const profileLabel = profileDisplayName ?? 'Profile'
@@ -785,6 +1143,58 @@ function App() {
useEffect(() => { useEffect(() => {
let isCancelled = false let isCancelled = false
async function boot() {
try {
const setupStatus = await fetchSetupStatus()
if (isCancelled) {
return
}
if (!setupStatus.completed) {
setBootState({ kind: 'setup', clientIp: setupStatus.client_ip })
navigateTo('/setup')
return
}
const me = await fetchMe()
if (isCancelled) {
return
}
if (!me) {
setBootState({ kind: 'login' })
navigateTo('/login')
return
}
setProfileDisplayName(me.username)
setBootState({ kind: 'ready', username: me.username })
if (
window.location.pathname === '/setup' ||
window.location.pathname === '/login'
) {
navigateTo('/')
}
} catch {
if (!isCancelled) {
setBootState({ kind: 'login' })
navigateTo('/login')
}
}
}
void boot()
return () => {
isCancelled = true
}
}, [])
useEffect(() => {
if (bootState.kind !== 'ready') {
return
}
let isCancelled = false
async function loadBackendVersion() { async function loadBackendVersion() {
try { try {
const status = await fetchStatus() const status = await fetchStatus()
@@ -803,9 +1213,12 @@ function App() {
return () => { return () => {
isCancelled = true isCancelled = true
} }
}, []) }, [bootState.kind])
function goToSection(section: SectionId) { function goToSection(section: SectionId) {
if (section === 'setup' || section === 'login') {
return
}
const nextTab = section === 'configuration' ? activeConfigTab : 'overview' const nextTab = section === 'configuration' ? activeConfigTab : 'overview'
setActiveSection(section) setActiveSection(section)
if (section !== 'configuration') { if (section !== 'configuration') {
@@ -820,6 +1233,39 @@ function App() {
navigateTo(pathFor('configuration', tab)) navigateTo(pathFor('configuration', tab))
} }
if (bootState.kind === 'loading') {
return (
<div className="wizard-shell">
<p className="config-hint">Loading</p>
</div>
)
}
if (bootState.kind === 'setup') {
return (
<SetupWizard
clientIp={bootState.clientIp}
onCompleted={() => {
setBootState({ kind: 'login' })
navigateTo('/login')
}}
/>
)
}
if (bootState.kind === 'login') {
return (
<LoginPage
onLoggedIn={(username) => {
setProfileDisplayName(username)
setBootState({ kind: 'ready', username })
navigateTo('/')
setActiveSection('overview')
}}
/>
)
}
return ( return (
<div className="app-shell"> <div className="app-shell">
<aside className="sidebar" aria-label="Main menu"> <aside className="sidebar" aria-label="Main menu">
@@ -830,7 +1276,23 @@ function App() {
</div> </div>
<nav className="sidebar-nav" aria-label="Sections"> <nav className="sidebar-nav" aria-label="Sections">
{NAV_ITEMS.map((item) => ( {NAV_ITEMS.slice(0, 1).map((item) => (
<button
key={item.id}
type="button"
className={
activeSection === item.id
? 'nav-item nav-item-active'
: 'nav-item'
}
aria-current={activeSection === item.id ? 'page' : undefined}
onClick={() => goToSection(item.id)}
>
{item.label}
</button>
))}
<div className="nav-section-divider" aria-hidden="true" />
{NAV_ITEMS.slice(1).map((item) => (
<button <button
key={item.id} key={item.id}
type="button" type="button"
+89
View File
@@ -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>
)
}
+567
View File
@@ -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="config-input"
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>
)
}
+171 -10
View File
@@ -1,11 +1,15 @@
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { import {
deleteGroup, deleteGroup,
deleteUser,
fetchGroups, fetchGroups,
fetchHealth, fetchHealth,
fetchNetwork,
fetchSecurity, fetchSecurity,
fetchStatus, fetchStatus,
fetchUsers,
getApiBaseUrl, getApiBaseUrl,
saveNetwork,
saveSecurity, saveSecurity,
upsertGroup, upsertGroup,
} from './client' } from './client'
@@ -14,6 +18,15 @@ describe('getApiBaseUrl', () => {
it('defaults to localhost:8080 when unset', () => { it('defaults to localhost:8080 when unset', () => {
expect(getApiBaseUrl()).toBe('http://localhost:8080') 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', () => { describe('fetchHealth', () => {
@@ -29,7 +42,10 @@ describe('fetchHealth', () => {
const health = await fetchHealth(fetchMock) const health = await fetchHealth(fetchMock)
expect(fetchMock).toHaveBeenCalledWith('http://localhost:8080/health') expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:8080/health',
expect.objectContaining({ credentials: 'include' }),
)
expect(health).toEqual({ status: 'ok' }) expect(health).toEqual({ status: 'ok' })
}) })
@@ -61,6 +77,7 @@ describe('fetchStatus', () => {
expect(fetchMock).toHaveBeenCalledWith( expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:8080/api/v1/status', 'http://localhost:8080/api/v1/status',
expect.objectContaining({ credentials: 'include' }),
) )
expect(status).toEqual({ service: 'clustercanvas', version: '0.1.0' }) expect(status).toEqual({ service: 'clustercanvas', version: '0.1.0' })
}) })
@@ -100,7 +117,10 @@ describe('fetchGroups', () => {
const payload = await fetchGroups(fetchMock) const payload = await fetchGroups(fetchMock)
expect(fetchMock).toHaveBeenCalledWith('http://localhost:8080/api/v1/groups') expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:8080/api/v1/groups',
expect.objectContaining({ credentials: 'include' }),
)
expect(payload.groups).toEqual([ expect(payload.groups).toEqual([
{ {
name: 'Admins', name: 'Admins',
@@ -145,9 +165,11 @@ describe('upsertGroup', () => {
fetchMock, fetchMock,
) )
expect(fetchMock).toHaveBeenCalledWith('http://localhost:8080/api/v1/groups', { expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:8080/api/v1/groups',
expect.objectContaining({
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ body: JSON.stringify({
group: { group: {
name: 'Admins', name: 'Admins',
@@ -156,7 +178,8 @@ describe('upsertGroup', () => {
permissions: ['nodes.read'], permissions: ['nodes.read'],
}, },
}), }),
}) }),
)
}) })
it('throws when the response is not ok', async () => { it('throws when the response is not ok', async () => {
@@ -195,7 +218,7 @@ describe('deleteGroup', () => {
expect(fetchMock).toHaveBeenCalledWith( expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:8080/api/v1/groups?name=Admins', 'http://localhost:8080/api/v1/groups?name=Admins',
{ method: 'DELETE' }, expect.objectContaining({ method: 'DELETE', credentials: 'include' }),
) )
}) })
@@ -207,7 +230,87 @@ describe('deleteGroup', () => {
}) })
await expect(deleteGroup('Admins', fetchMock)).rejects.toThrow( await expect(deleteGroup('Admins', fetchMock)).rejects.toThrow(
'groups delete failed: 404', '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 and returns users', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ users: [] }),
})
await deleteUser('u2', fetchMock)
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:8080/api/v1/users?id=u2',
expect.objectContaining({ method: 'DELETE', credentials: 'include' }),
)
})
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', fetchMock)).rejects.toThrow(
'cannot delete the last administrator',
) )
}) })
}) })
@@ -234,6 +337,7 @@ describe('fetchSecurity', () => {
expect(fetchMock).toHaveBeenCalledWith( expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:8080/api/v1/security', 'http://localhost:8080/api/v1/security',
expect.objectContaining({ credentials: 'include' }),
) )
expect(payload.security).toEqual(security) expect(payload.security).toEqual(security)
}) })
@@ -273,11 +377,11 @@ describe('saveSecurity', () => {
expect(fetchMock).toHaveBeenCalledWith( expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:8080/api/v1/security', 'http://localhost:8080/api/v1/security',
{ expect.objectContaining({
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ security }), body: JSON.stringify({ security }),
}, }),
) )
}) })
@@ -302,3 +406,60 @@ describe('saveSecurity', () => {
).rejects.toThrow('security save failed: 400') ).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 }),
}),
)
})
})
+305 -14
View File
@@ -20,6 +20,22 @@ export type GroupsResponse = {
groups: ReadonlyArray<Group> 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 = { export type SecuritySettings = {
idle_timeout_minutes: number idle_timeout_minutes: number
session_lifetime_hours: number session_lifetime_hours: number
@@ -32,6 +48,50 @@ export type SecurityResponse = {
security: SecuritySettings 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>
}
export type NetworkPreviewResponse = {
would_lock_out: boolean
reason?: string
client_ip: string
}
export const DEFAULT_SECURITY_SETTINGS: SecuritySettings = { export const DEFAULT_SECURITY_SETTINGS: SecuritySettings = {
idle_timeout_minutes: 30, idle_timeout_minutes: 30,
session_lifetime_hours: 24, session_lifetime_hours: 24,
@@ -40,14 +100,54 @@ export const DEFAULT_SECURITY_SETTINGS: SecuritySettings = {
totp_enabled: false, 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 { export function getApiBaseUrl(): string {
return import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080' // 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( export async function fetchHealth(
fetchImpl: typeof fetch = fetch, fetchImpl: typeof fetch = fetch,
): Promise<HealthResponse> { ): Promise<HealthResponse> {
const response = await fetchImpl(`${getApiBaseUrl()}/health`) const response = await apiFetch('/health', {}, fetchImpl)
if (!response.ok) { if (!response.ok) {
throw new Error(`health check failed: ${response.status}`) throw new Error(`health check failed: ${response.status}`)
} }
@@ -57,7 +157,7 @@ export async function fetchHealth(
export async function fetchStatus( export async function fetchStatus(
fetchImpl: typeof fetch = fetch, fetchImpl: typeof fetch = fetch,
): Promise<StatusResponse> { ): Promise<StatusResponse> {
const response = await fetchImpl(`${getApiBaseUrl()}/api/v1/status`) const response = await apiFetch('/api/v1/status', {}, fetchImpl)
if (!response.ok) { if (!response.ok) {
throw new Error(`status check failed: ${response.status}`) throw new Error(`status check failed: ${response.status}`)
} }
@@ -67,7 +167,7 @@ export async function fetchStatus(
export async function fetchGroups( export async function fetchGroups(
fetchImpl: typeof fetch = fetch, fetchImpl: typeof fetch = fetch,
): Promise<GroupsResponse> { ): Promise<GroupsResponse> {
const response = await fetchImpl(`${getApiBaseUrl()}/api/v1/groups`) const response = await apiFetch('/api/v1/groups', {}, fetchImpl)
if (!response.ok) { if (!response.ok) {
throw new Error(`groups fetch failed: ${response.status}`) throw new Error(`groups fetch failed: ${response.status}`)
} }
@@ -78,11 +178,14 @@ export async function upsertGroup(
group: Group, group: Group,
fetchImpl: typeof fetch = fetch, fetchImpl: typeof fetch = fetch,
): Promise<GroupsResponse> { ): Promise<GroupsResponse> {
const response = await fetchImpl(`${getApiBaseUrl()}/api/v1/groups`, { const response = await apiFetch(
'/api/v1/groups',
{
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ group }), body: JSON.stringify({ group }),
}) },
fetchImpl,
)
if (!response.ok) { if (!response.ok) {
throw new Error(`groups upsert failed: ${response.status}`) throw new Error(`groups upsert failed: ${response.status}`)
@@ -95,22 +198,48 @@ export async function deleteGroup(
name: string, name: string,
fetchImpl: typeof fetch = fetch, fetchImpl: typeof fetch = fetch,
): Promise<GroupsResponse> { ): Promise<GroupsResponse> {
const response = await fetchImpl( const response = await apiFetch(
`${getApiBaseUrl()}/api/v1/groups?name=${encodeURIComponent(name)}`, `/api/v1/groups?name=${encodeURIComponent(name)}`,
{ method: 'DELETE' }, { method: 'DELETE' },
fetchImpl,
) )
if (!response.ok) { if (!response.ok) {
throw new Error(`groups delete failed: ${response.status}`) throw new Error(await readErrorMessage(response))
} }
return (await response.json()) as GroupsResponse 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 deleteUser(
id: string,
fetchImpl: typeof fetch = fetch,
): Promise<UsersResponse> {
const response = await apiFetch(
`/api/v1/users?id=${encodeURIComponent(id)}`,
{ method: 'DELETE' },
fetchImpl,
)
if (!response.ok) {
throw new Error(await readErrorMessage(response))
}
return (await response.json()) as UsersResponse
}
export async function fetchSecurity( export async function fetchSecurity(
fetchImpl: typeof fetch = fetch, fetchImpl: typeof fetch = fetch,
): Promise<SecurityResponse> { ): Promise<SecurityResponse> {
const response = await fetchImpl(`${getApiBaseUrl()}/api/v1/security`) const response = await apiFetch('/api/v1/security', {}, fetchImpl)
if (!response.ok) { if (!response.ok) {
throw new Error(`security fetch failed: ${response.status}`) throw new Error(`security fetch failed: ${response.status}`)
} }
@@ -121,11 +250,14 @@ export async function saveSecurity(
security: SecuritySettings, security: SecuritySettings,
fetchImpl: typeof fetch = fetch, fetchImpl: typeof fetch = fetch,
): Promise<SecurityResponse> { ): Promise<SecurityResponse> {
const response = await fetchImpl(`${getApiBaseUrl()}/api/v1/security`, { const response = await apiFetch(
'/api/v1/security',
{
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ security }), body: JSON.stringify({ security }),
}) },
fetchImpl,
)
if (!response.ok) { if (!response.ok) {
throw new Error(`security save failed: ${response.status}`) throw new Error(`security save failed: ${response.status}`)
@@ -133,3 +265,162 @@ export async function saveSecurity(
return (await response.json()) as SecurityResponse 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
}
+10
View File
@@ -5,8 +5,11 @@ describe('pathFor', () => {
it('maps sections and config tabs to paths', () => { it('maps sections and config tabs to paths', () => {
expect(pathFor('overview')).toBe('/') expect(pathFor('overview')).toBe('/')
expect(pathFor('vms')).toBe('/vms') expect(pathFor('vms')).toBe('/vms')
expect(pathFor('docker')).toBe('/docker')
expect(pathFor('containers')).toBe('/containers') expect(pathFor('containers')).toBe('/containers')
expect(pathFor('profile')).toBe('/profile') expect(pathFor('profile')).toBe('/profile')
expect(pathFor('setup')).toBe('/setup')
expect(pathFor('login')).toBe('/login')
expect(pathFor('configuration')).toBe('/configuration') expect(pathFor('configuration')).toBe('/configuration')
expect(pathFor('configuration', 'overview')).toBe('/configuration') expect(pathFor('configuration', 'overview')).toBe('/configuration')
expect(pathFor('configuration', 'groups')).toBe('/configuration/groups') expect(pathFor('configuration', 'groups')).toBe('/configuration/groups')
@@ -19,8 +22,11 @@ describe('parseLocation', () => {
for (const path of [ for (const path of [
'/', '/',
'/vms', '/vms',
'/docker',
'/containers', '/containers',
'/profile', '/profile',
'/setup',
'/login',
'/configuration', '/configuration',
'/configuration/users', '/configuration/users',
'/configuration/roles', '/configuration/roles',
@@ -48,6 +54,10 @@ describe('parseLocation', () => {
section: 'overview', section: 'overview',
configTab: 'overview', configTab: 'overview',
}) })
expect(parseLocation('/docker/extra')).toEqual({
section: 'overview',
configTab: 'overview',
})
}) })
it('treats trailing slashes as the same path', () => { it('treats trailing slashes as the same path', () => {
+24
View File
@@ -1,9 +1,12 @@
export type SectionId = export type SectionId =
| 'overview' | 'overview'
| 'vms' | 'vms'
| 'docker'
| 'containers' | 'containers'
| 'profile' | 'profile'
| 'configuration' | 'configuration'
| 'setup'
| 'login'
export type ConfigTabId = export type ConfigTabId =
| 'overview' | 'overview'
@@ -51,10 +54,22 @@ export function parseLocation(pathname: string): AppLocation {
const [first, second] = segments const [first, second] = segments
if (first === 'setup' && segments.length === 1) {
return { section: 'setup', configTab: 'overview' }
}
if (first === 'login' && segments.length === 1) {
return { section: 'login', configTab: 'overview' }
}
if (first === 'vms' && segments.length === 1) { if (first === 'vms' && segments.length === 1) {
return { section: 'vms', configTab: 'overview' } return { section: 'vms', configTab: 'overview' }
} }
if (first === 'docker' && segments.length === 1) {
return { section: 'docker', configTab: 'overview' }
}
if (first === 'containers' && segments.length === 1) { if (first === 'containers' && segments.length === 1) {
return { section: 'containers', configTab: 'overview' } return { section: 'containers', configTab: 'overview' }
} }
@@ -83,9 +98,18 @@ export function pathFor(
if (section === 'overview') { if (section === 'overview') {
return '/' return '/'
} }
if (section === 'setup') {
return '/setup'
}
if (section === 'login') {
return '/login'
}
if (section === 'vms') { if (section === 'vms') {
return '/vms' return '/vms'
} }
if (section === 'docker') {
return '/docker'
}
if (section === 'containers') { if (section === 'containers') {
return '/containers' return '/containers'
} }
+29
View File
@@ -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()
})
})
+76
View File
@@ -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'
}