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:
@@ -17,6 +17,7 @@ webui/coverage/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
remotedev.mk
|
||||
|
||||
# IDE / OS
|
||||
.idea/
|
||||
|
||||
@@ -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)
|
||||
|
||||
# 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:
|
||||
cd service && go run ./cmd/server
|
||||
|
||||
@@ -9,12 +26,40 @@ webui:
|
||||
cd webui && npm run dev
|
||||
|
||||
# Run API (:8080) and web UI (:5173) together; Ctrl+C stops both.
|
||||
# Export CLUSTERCANVAS_CONFIG_KEY in your shell (or remotedev.mk) before first-run setup.
|
||||
dev:
|
||||
@trap 'kill 0' EXIT INT TERM; \
|
||||
(cd service && go run ./cmd/server) & \
|
||||
(cd webui && npm run dev) & \
|
||||
wait
|
||||
|
||||
# Build locally, copy binaries + SPA to a VM over SSH, run both there (no SDKs on remote).
|
||||
# Prepare remotedev.mk with REMOTEDEV_USER + REMOTEDEV_HOST, or pass them on the CLI.
|
||||
# Usage: make remotedev
|
||||
# or: REMOTEDEV_HOST=user@192.168.1.50 make remotedev
|
||||
remotedev:
|
||||
@test -n "$(REMOTEDEV_HOST)" || (echo 'Set REMOTEDEV_HOST (and optional REMOTEDEV_USER) in remotedev.mk or on the CLI'; exit 1)
|
||||
mkdir -p bin
|
||||
cd service && go build -o ../bin/clustercanvas-server ./cmd/server
|
||||
cd service && go build -o ../bin/clustercanvas-webui ./cmd/webui
|
||||
cd webui && VITE_API_BASE_URL= npm run build
|
||||
# scp only needs OpenSSH on the remote (rsync is often missing on minimal VMs).
|
||||
# Leave ~ unquoted so the remote shell expands REMOTEDEV_DIR=~/... correctly.
|
||||
ssh "$(REMOTEDEV_SSH)" "mkdir -p $(REMOTEDEV_DIR)/web $(REMOTEDEV_DIR)/config"
|
||||
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:
|
||||
cd service && go test ./...
|
||||
cd webui && npm test
|
||||
@@ -22,6 +67,7 @@ test:
|
||||
build:
|
||||
mkdir -p bin
|
||||
cd service && go build -o ../bin/clustercanvas-server ./cmd/server
|
||||
cd service && go build -o ../bin/clustercanvas-webui ./cmd/webui
|
||||
cd webui && npm run build
|
||||
|
||||
clean:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ClusterCanvas
|
||||
|
||||
A web-based dashboard for managing Proxmox VMs and LXC containers. After configuration it discovers hosts and shows per‑instance details like hostname, storage and RAM usage, and status. From the same interface you can view logs, run updates, deploy changes, and perform other routine maintenance tasks.
|
||||
A web-based dashboard for managing Proxmox VMs, LXC containers, and Docker. After configuration it discovers hosts and shows per‑instance details like hostname, storage and RAM usage, and status. From the same interface you can view logs, run updates, deploy changes, and perform other routine maintenance tasks.
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -11,7 +11,7 @@ A web-based dashboard for managing Proxmox VMs and LXC containers. After configu
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Go 1.22+
|
||||
- Go 1.22+ (toolchain may download a newer Go as needed)
|
||||
- Node.js 20+ and npm
|
||||
|
||||
## Configuration
|
||||
@@ -20,8 +20,10 @@ Service config lives under **`/etc/ClusterCanvas/`** by default:
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `settings.json` | Plain base settings (non-secret) |
|
||||
| `secrets.enc` | AES-256-GCM encrypted JSON secrets |
|
||||
| `settings.json` | Plain base settings (non-secret), including setup/network/security/groups |
|
||||
| `passwords.enc` | AES-GCM encrypted user credentials (argon2id hashes, TOTP secrets) |
|
||||
| `sessions.enc` | AES-GCM encrypted session store |
|
||||
| `secrets.enc` | AES-GCM encrypted JSON secrets (integrations, etc.) |
|
||||
|
||||
**Config directory precedence** (highest first):
|
||||
|
||||
@@ -31,13 +33,30 @@ Service config lives under **`/etc/ClusterCanvas/`** by default:
|
||||
|
||||
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
|
||||
openssl rand -base64 32
|
||||
export CLUSTERCANVAS_CONFIG_KEY='…paste output…'
|
||||
```
|
||||
|
||||
For a one-off shell session you can also prefix the command:
|
||||
|
||||
```bash
|
||||
CLUSTERCANVAS_CONFIG_KEY="$(openssl rand -base64 32)" make service
|
||||
# or with make dev:
|
||||
export CLUSTERCANVAS_CONFIG_KEY="$(openssl rand -base64 32)"
|
||||
make dev
|
||||
```
|
||||
|
||||
Keep the same key across restarts if you already ran setup — changing it makes existing `passwords.enc` / `sessions.enc` unreadable.
|
||||
|
||||
### First-run setup
|
||||
|
||||
On a clean install (`setup_completed` is false / missing), the web UI opens a setup wizard instead of the dashboard. It creates the admin user, Administrators group, network settings (listen address, optional public hostname, IP allow/deny lists), and security policy, then requires sign-in.
|
||||
|
||||
Session cookies use the `__Host-ClusterCanvas-Session` name with `Secure`, `HttpOnly`, `SameSite=Strict`, and `Path=/` (no `Domain`). Production should set a **public hostname** and terminate TLS at a reverse proxy so browsers reach the UI over HTTPS. Listen address changes apply on the next process restart.
|
||||
|
||||
## Run
|
||||
|
||||
Start API and web UI together (API `:8080`, UI `:5173`; Ctrl+C stops both):
|
||||
@@ -59,8 +78,39 @@ Custom config directory for the API:
|
||||
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
|
||||
|
||||
```bash
|
||||
@@ -75,4 +125,4 @@ This runs `go test ./...` in `service/` and Vitest in `webui/`.
|
||||
make build
|
||||
```
|
||||
|
||||
Produces `bin/clustercanvas-server` and `webui/dist/`.
|
||||
Produces `bin/clustercanvas-server`, `bin/clustercanvas-webui`, and `webui/dist/`.
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# Local remotedev settings (copy to remotedev.mk — that file is gitignored).
|
||||
# Then run: make remotedev
|
||||
|
||||
REMOTEDEV_USER = youruser
|
||||
REMOTEDEV_HOST = 192.168.1.50
|
||||
|
||||
# Optional:
|
||||
# REMOTEDEV_DIR = clustercanvas-remotedev
|
||||
# PORT = 8080
|
||||
# WEBUI_PORT = 5173
|
||||
# Override the baked-in remotedev encryption key (dev only):
|
||||
# REMOTEDEV_CONFIG_KEY = $(shell openssl rand -base64 32)
|
||||
@@ -34,14 +34,16 @@ func main() {
|
||||
port = "8080"
|
||||
}
|
||||
|
||||
listenHost := settings.ListenHost(configDir)
|
||||
listenAddr := listenHost + ":" + port
|
||||
server := &http.Server{
|
||||
Addr: ":" + port,
|
||||
Addr: listenAddr,
|
||||
Handler: api.NewRouter(configDir),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
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) {
|
||||
log.Fatalf("listen: %v", err)
|
||||
}
|
||||
|
||||
@@ -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
@@ -1,3 +1,13 @@
|
||||
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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||
@@ -103,6 +102,13 @@ func groupsDeleteHandler(configDir string) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
if name == settings.AdministratorsGroupName {
|
||||
writeJSON(writer, http.StatusConflict, apiErrorResponse{
|
||||
Error: "Administrators group cannot be deleted",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
settingsPayload, err := loadSettingsOrDefault(configDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
@@ -136,20 +142,6 @@ func authorizeGroupsWrite(_ *http.Request) error {
|
||||
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 {
|
||||
if strings.TrimSpace(group.Name) == "" {
|
||||
return errors.New("group.name is required")
|
||||
|
||||
@@ -18,13 +18,12 @@ type apiErrorResponseTest struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func TestGroupsGetEmptyWhenSettingsMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
func TestGroupsGetEmptyWhenNoGroups(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, 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()
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
@@ -43,9 +42,8 @@ func TestGroupsGetEmptyWhenSettingsMissing(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGroupsUpsertRejectsInvalidPermissions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
router := NewRouter(configDir)
|
||||
|
||||
body := []byte(`{
|
||||
@@ -56,7 +54,7 @@ func TestGroupsUpsertRejectsInvalidPermissions(t *testing.T) {
|
||||
"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()
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
@@ -75,9 +73,8 @@ func TestGroupsUpsertRejectsInvalidPermissions(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGroupsUpsertUpsertsByName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
router := NewRouter(configDir)
|
||||
|
||||
createBody := []byte(`{
|
||||
@@ -88,7 +85,7 @@ func TestGroupsUpsertUpsertsByName(t *testing.T) {
|
||||
"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()
|
||||
router.ServeHTTP(createRec, createReq)
|
||||
if createRec.Code != http.StatusOK {
|
||||
@@ -103,14 +100,14 @@ func TestGroupsUpsertUpsertsByName(t *testing.T) {
|
||||
"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()
|
||||
router.ServeHTTP(updateRec, updateReq)
|
||||
if updateRec.Code != http.StatusOK {
|
||||
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()
|
||||
router.ServeHTTP(getRec, getReq)
|
||||
if getRec.Code != http.StatusOK {
|
||||
@@ -133,9 +130,8 @@ func TestGroupsUpsertUpsertsByName(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGroupsDeleteRemovesByName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
router := NewRouter(configDir)
|
||||
|
||||
createBody := []byte(`{
|
||||
@@ -146,21 +142,21 @@ func TestGroupsDeleteRemovesByName(t *testing.T) {
|
||||
"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()
|
||||
router.ServeHTTP(createRec, createReq)
|
||||
if createRec.Code != http.StatusOK {
|
||||
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()
|
||||
router.ServeHTTP(deleteRec, deleteReq)
|
||||
if deleteRec.Code != http.StatusOK {
|
||||
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()
|
||||
router.ServeHTTP(getRec, getReq)
|
||||
if getRec.Code != http.StatusOK {
|
||||
@@ -177,12 +173,11 @@ func TestGroupsDeleteRemovesByName(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGroupsDeleteMissingNameFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, 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()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ func TestHealthHandler(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusHandler(t *testing.T) {
|
||||
func TestStatusHandlerRequiresSetup(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
configDir := t.TempDir()
|
||||
@@ -41,6 +41,19 @@ func TestStatusHandler(t *testing.T) {
|
||||
|
||||
NewRouter(configDir).ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusServiceUnavailable, recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusHandler(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/status", nil), cookie)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
NewRouter(configDir).ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, recorder.Code)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -6,29 +6,37 @@ import (
|
||||
|
||||
const defaultAllowedOrigin = "http://localhost:5173"
|
||||
|
||||
func withCORS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.Header().Set("Access-Control-Allow-Origin", defaultAllowedOrigin)
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
// NewRouter builds the HTTP API with setup gating, auth, and network middleware.
|
||||
func NewRouter(configDir string) http.Handler {
|
||||
app, err := newApp(configDir)
|
||||
if err != nil {
|
||||
// newApp currently always succeeds; keep signature for future.
|
||||
panic(err)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /health", HealthHandler)
|
||||
mux.HandleFunc("GET /api/v1/status", StatusHandler)
|
||||
|
||||
mux.HandleFunc("GET /api/v1/setup/status", app.setupStatusHandler)
|
||||
mux.HandleFunc("POST /api/v1/setup/totp/begin", app.setupTOTPBeginHandler)
|
||||
mux.HandleFunc("POST /api/v1/setup/totp/verify", app.setupTOTPVerifyHandler)
|
||||
mux.HandleFunc("POST /api/v1/setup/network/preview", app.setupPreviewHandler)
|
||||
mux.HandleFunc("POST /api/v1/setup/complete", app.setupCompleteHandler)
|
||||
|
||||
mux.HandleFunc("POST /api/v1/auth/login", app.loginHandler)
|
||||
mux.HandleFunc("POST /api/v1/auth/logout", app.logoutHandler)
|
||||
mux.HandleFunc("GET /api/v1/auth/me", app.meHandler)
|
||||
|
||||
mux.HandleFunc("GET /api/v1/groups", groupsGetHandler(configDir))
|
||||
mux.HandleFunc("POST /api/v1/groups", groupsUpsertHandler(configDir))
|
||||
mux.HandleFunc("DELETE /api/v1/groups", groupsDeleteHandler(configDir))
|
||||
mux.HandleFunc("GET /api/v1/security", securityGetHandler(configDir))
|
||||
mux.HandleFunc("PUT /api/v1/security", securityPutHandler(configDir))
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -15,12 +15,11 @@ type securityResponseTest struct {
|
||||
}
|
||||
|
||||
func TestSecurityGetReturnsDefaultsWhenMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, 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()
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
@@ -40,9 +39,8 @@ func TestSecurityGetReturnsDefaultsWhenMissing(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSecurityPutRejectsIdleTimeoutZero(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
router := NewRouter(configDir)
|
||||
|
||||
body := []byte(`{
|
||||
@@ -53,7 +51,7 @@ func TestSecurityPutRejectsIdleTimeoutZero(t *testing.T) {
|
||||
"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()
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
@@ -63,9 +61,8 @@ func TestSecurityPutRejectsIdleTimeoutZero(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSecurityPutRejectsIdleTimeoutAboveMax(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
router := NewRouter(configDir)
|
||||
|
||||
body := []byte(`{
|
||||
@@ -76,7 +73,7 @@ func TestSecurityPutRejectsIdleTimeoutAboveMax(t *testing.T) {
|
||||
"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()
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
@@ -86,9 +83,8 @@ func TestSecurityPutRejectsIdleTimeoutAboveMax(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSecurityPutRejectsSessionLifetimeZero(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
router := NewRouter(configDir)
|
||||
|
||||
body := []byte(`{
|
||||
@@ -99,7 +95,7 @@ func TestSecurityPutRejectsSessionLifetimeZero(t *testing.T) {
|
||||
"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()
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
@@ -109,9 +105,8 @@ func TestSecurityPutRejectsSessionLifetimeZero(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSecurityPutRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
router := NewRouter(configDir)
|
||||
|
||||
body := []byte(`{
|
||||
@@ -123,15 +118,15 @@ func TestSecurityPutRoundTrip(t *testing.T) {
|
||||
"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()
|
||||
router.ServeHTTP(putRec, putReq)
|
||||
|
||||
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()
|
||||
router.ServeHTTP(getRec, getReq)
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// NewRandomID returns a URL-safe high-entropy identifier.
|
||||
func NewRandomID() (string, error) {
|
||||
buffer := make([]byte, 16)
|
||||
if _, err := rand.Read(buffer); err != nil {
|
||||
return "", fmt.Errorf("random id: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buffer), nil
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||
)
|
||||
|
||||
var hostnamePattern = regexp.MustCompile(`^(?i)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$|^localhost$`)
|
||||
|
||||
// ValidateHostname checks a public hostname (empty is allowed for local/dev).
|
||||
func ValidateHostname(hostname string) error {
|
||||
trimmed := strings.TrimSpace(hostname)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(trimmed, "/") || strings.Contains(trimmed, ":") || strings.Contains(trimmed, " ") {
|
||||
return fmt.Errorf("public_hostname must be a bare DNS name without scheme, port, or path")
|
||||
}
|
||||
if !hostnamePattern.MatchString(trimmed) {
|
||||
return fmt.Errorf("public_hostname is not a valid DNS hostname")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HostMatches reports whether requestHost matches the configured public hostname.
|
||||
// An empty / whitespace public hostname means any host is allowed (local/dev).
|
||||
func HostMatches(requestHost string, publicHostname string) bool {
|
||||
wanted := strings.TrimSpace(publicHostname)
|
||||
if wanted == "" {
|
||||
return true
|
||||
}
|
||||
host := strings.TrimSpace(requestHost)
|
||||
if parsedHost, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = parsedHost
|
||||
}
|
||||
return strings.EqualFold(host, wanted)
|
||||
}
|
||||
|
||||
// IPAllowed evaluates whitelist/blacklist rules against clientIP.
|
||||
func IPAllowed(clientIP string, network settings.NetworkSettings) (bool, string) {
|
||||
network = settings.EffectiveNetwork(network)
|
||||
parsedIP := net.ParseIP(clientIP)
|
||||
if parsedIP == nil {
|
||||
return false, "unable to parse client IP"
|
||||
}
|
||||
|
||||
switch network.AccessMode {
|
||||
case settings.AccessModeOpen, "":
|
||||
return true, ""
|
||||
case settings.AccessModeWhitelist:
|
||||
if matchesAnyRule(parsedIP, network.Rules) {
|
||||
return true, ""
|
||||
}
|
||||
return false, "client IP is not on the whitelist"
|
||||
case settings.AccessModeBlacklist:
|
||||
if matchesAnyRule(parsedIP, network.Rules) {
|
||||
return false, "client IP matches a blacklist rule"
|
||||
}
|
||||
return true, ""
|
||||
default:
|
||||
return false, "invalid access mode"
|
||||
}
|
||||
}
|
||||
|
||||
// WouldLockOut estimates whether rules would block clientIP.
|
||||
func WouldLockOut(clientIP string, network settings.NetworkSettings) (bool, string) {
|
||||
allowed, reason := IPAllowed(clientIP, network)
|
||||
if allowed {
|
||||
return false, ""
|
||||
}
|
||||
return true, reason
|
||||
}
|
||||
|
||||
func matchesAnyRule(ip net.IP, rules []string) bool {
|
||||
for _, rule := range rules {
|
||||
trimmed := strings.TrimSpace(rule)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(trimmed, "/") {
|
||||
_, network, err := net.ParseCIDR(trimmed)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if network.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
ruleIP := net.ParseIP(trimmed)
|
||||
if ruleIP != nil && ruleIP.Equal(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ValidateListenAddress checks a bind address (IP or hostname).
|
||||
func ValidateListenAddress(address string) error {
|
||||
trimmed := strings.TrimSpace(address)
|
||||
if trimmed == "" {
|
||||
return fmt.Errorf("listen_address is required")
|
||||
}
|
||||
if trimmed == "0.0.0.0" || trimmed == "::" || trimmed == "localhost" {
|
||||
return nil
|
||||
}
|
||||
if net.ParseIP(trimmed) != nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("listen_address must be an IP address or 0.0.0.0")
|
||||
}
|
||||
|
||||
// ValidateAccessRules validates CIDR/IP entries.
|
||||
func ValidateAccessRules(rules []string) error {
|
||||
for _, rule := range rules {
|
||||
trimmed := strings.TrimSpace(rule)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(trimmed, "/") {
|
||||
if _, _, err := net.ParseCIDR(trimmed); err != nil {
|
||||
return fmt.Errorf("invalid CIDR rule %q", trimmed)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if net.ParseIP(trimmed) == nil {
|
||||
return fmt.Errorf("invalid IP rule %q", trimmed)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
const (
|
||||
argon2Time = 3
|
||||
argon2Memory = 64 * 1024
|
||||
argon2Threads = 4
|
||||
argon2KeyLen = 32
|
||||
argon2SaltLen = 16
|
||||
|
||||
minPasswordLength = 14
|
||||
minPassphraseWords = 5
|
||||
maxPasswordLength = 256
|
||||
)
|
||||
|
||||
var (
|
||||
ErrPasswordTooLong = errors.New("password must be at most 256 characters")
|
||||
ErrPasswordTooWeak = errors.New("password must be at least 14 characters or at least 5 words")
|
||||
ErrPasswordMatchesUser = errors.New("password cannot match the login name")
|
||||
ErrPasswordCommon = errors.New("password is too common")
|
||||
ErrPasswordNotPrintable = errors.New("password may only contain printable characters")
|
||||
)
|
||||
|
||||
// ValidatePassword checks password policy rules.
|
||||
func ValidatePassword(password string, username string) error {
|
||||
if len(password) > maxPasswordLength {
|
||||
return ErrPasswordTooLong
|
||||
}
|
||||
for _, runeValue := range password {
|
||||
if !unicode.IsPrint(runeValue) {
|
||||
return ErrPasswordNotPrintable
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(password), strings.TrimSpace(username)) {
|
||||
return ErrPasswordMatchesUser
|
||||
}
|
||||
if isCommonPassword(password) {
|
||||
return ErrPasswordCommon
|
||||
}
|
||||
|
||||
wordCount := countWords(password)
|
||||
if len(password) >= minPasswordLength || wordCount >= minPassphraseWords {
|
||||
return nil
|
||||
}
|
||||
return ErrPasswordTooWeak
|
||||
}
|
||||
|
||||
func countWords(password string) int {
|
||||
fields := strings.Fields(password)
|
||||
return len(fields)
|
||||
}
|
||||
|
||||
// HashPassword returns a PHC-formatted argon2id hash string.
|
||||
func HashPassword(password string) (string, error) {
|
||||
salt := make([]byte, argon2SaltLen)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", fmt.Errorf("generate salt: %w", err)
|
||||
}
|
||||
|
||||
hash := argon2.IDKey(
|
||||
[]byte(password),
|
||||
salt,
|
||||
argon2Time,
|
||||
argon2Memory,
|
||||
argon2Threads,
|
||||
argon2KeyLen,
|
||||
)
|
||||
|
||||
return fmt.Sprintf(
|
||||
"$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||||
argon2.Version,
|
||||
argon2Memory,
|
||||
argon2Time,
|
||||
argon2Threads,
|
||||
base64.RawStdEncoding.EncodeToString(salt),
|
||||
base64.RawStdEncoding.EncodeToString(hash),
|
||||
), nil
|
||||
}
|
||||
|
||||
// VerifyPassword compares password against a PHC argon2id hash.
|
||||
func VerifyPassword(password string, encodedHash string) (bool, error) {
|
||||
parts := strings.Split(encodedHash, "$")
|
||||
if len(parts) != 6 || parts[1] != "argon2id" {
|
||||
return false, errors.New("unsupported password hash format")
|
||||
}
|
||||
|
||||
var version int
|
||||
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
|
||||
return false, errors.New("invalid hash version")
|
||||
}
|
||||
|
||||
var memory uint32
|
||||
var timeCost uint32
|
||||
var threads uint8
|
||||
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &timeCost, &threads); err != nil {
|
||||
return false, errors.New("invalid hash parameters")
|
||||
}
|
||||
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
if err != nil {
|
||||
return false, errors.New("invalid hash salt")
|
||||
}
|
||||
expectedHash, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||
if err != nil {
|
||||
return false, errors.New("invalid hash digest")
|
||||
}
|
||||
|
||||
computed := argon2.IDKey(
|
||||
[]byte(password),
|
||||
salt,
|
||||
timeCost,
|
||||
memory,
|
||||
threads,
|
||||
uint32(len(expectedHash)),
|
||||
)
|
||||
return subtle.ConstantTimeCompare(computed, expectedHash) == 1, nil
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||
)
|
||||
|
||||
func TestValidatePasswordAcceptsLongRandom(t *testing.T) {
|
||||
t.Parallel()
|
||||
if err := ValidatePassword("Tr0ub4dor&3-extra!", "Admin"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePasswordAcceptsPassphrase(t *testing.T) {
|
||||
t.Parallel()
|
||||
if err := ValidatePassword("apple banana cherry date elderberry", "Admin"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePasswordRejectsShort(t *testing.T) {
|
||||
t.Parallel()
|
||||
if err := ValidatePassword("short-pass", "Admin"); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePasswordRejectsUsername(t *testing.T) {
|
||||
t.Parallel()
|
||||
if err := ValidatePassword("AdminAdminAdmin!", "AdminAdminAdmin!"); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashAndVerifyPassword(t *testing.T) {
|
||||
t.Parallel()
|
||||
hash, err := HashPassword("apple banana cherry date elderberry")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword: %v", err)
|
||||
}
|
||||
ok, err := VerifyPassword("apple banana cherry date elderberry", hash)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("VerifyPassword failed: ok=%v err=%v", ok, err)
|
||||
}
|
||||
ok, err = VerifyPassword("wrong password here!!", hash)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyPassword error: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("expected mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWouldLockOutWhitelist(t *testing.T) {
|
||||
t.Parallel()
|
||||
network := settings.NetworkSettings{
|
||||
AccessMode: settings.AccessModeWhitelist,
|
||||
Rules: []string{"10.0.0.0/8"},
|
||||
}
|
||||
locked, _ := WouldLockOut("192.168.1.5", network)
|
||||
if !locked {
|
||||
t.Fatal("expected lock out")
|
||||
}
|
||||
locked, _ = WouldLockOut("10.1.2.3", network)
|
||||
if locked {
|
||||
t.Fatal("expected allow")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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 = ©Session
|
||||
continue
|
||||
}
|
||||
remaining = append(remaining, session)
|
||||
}
|
||||
|
||||
if found == nil {
|
||||
store.Sessions = remaining
|
||||
_ = settings.SaveSessions(manager.configDir, store, manager.key)
|
||||
clearSessionCookie(writer)
|
||||
return settings.SessionRecord{}, ErrSessionNotFound
|
||||
}
|
||||
|
||||
shouldRotate := now.Sub(found.CreatedAt) >= sessionRotateAfter ||
|
||||
now.Sub(found.CreatedAt) >= lifetime/2
|
||||
|
||||
found.LastSeenAt = now
|
||||
if shouldRotate {
|
||||
newID, err := newSessionID()
|
||||
if err != nil {
|
||||
return settings.SessionRecord{}, err
|
||||
}
|
||||
found.ID = newID
|
||||
found.CreatedAt = now
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/pquerna/otp"
|
||||
"github.com/pquerna/otp/totp"
|
||||
)
|
||||
|
||||
const totpIssuer = "ClusterCanvas"
|
||||
|
||||
// GenerateTOTPSecret creates a new TOTP key for username.
|
||||
func GenerateTOTPSecret(username string) (*otp.Key, error) {
|
||||
key, err := totp.Generate(totp.GenerateOpts{
|
||||
Issuer: totpIssuer,
|
||||
AccountName: username,
|
||||
Period: 30,
|
||||
Digits: otp.DigitsSix,
|
||||
Algorithm: otp.AlgorithmSHA1,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate totp: %w", err)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// VerifyTOTPCode validates a TOTP code against a base32 secret.
|
||||
func VerifyTOTPCode(secret string, code string) bool {
|
||||
return totp.Validate(code, secret)
|
||||
}
|
||||
|
||||
// OTPAuthURL builds an otpauth URL from an existing secret.
|
||||
func OTPAuthURL(username string, secret string) (string, error) {
|
||||
key, err := otp.NewKeyFromURL(
|
||||
fmt.Sprintf(
|
||||
"otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=SHA1&digits=6&period=30",
|
||||
totpIssuer,
|
||||
username,
|
||||
secret,
|
||||
totpIssuer,
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return key.URL(), nil
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package auth
|
||||
|
||||
import "strings"
|
||||
|
||||
// Small denylist of common weak passwords (case-insensitive compare).
|
||||
var commonPasswords = map[string]struct{}{
|
||||
"password": {},
|
||||
"password123": {},
|
||||
"password1234": {},
|
||||
"12345678901234": {},
|
||||
"qwertyuiopasdf": {},
|
||||
"admin": {},
|
||||
"adminadmin": {},
|
||||
"letmein": {},
|
||||
"welcome": {},
|
||||
"welcome123": {},
|
||||
"changeme": {},
|
||||
"changeme123": {},
|
||||
"cluster canvas": {},
|
||||
"clustercanvas": {},
|
||||
"correct horse battery staple": {},
|
||||
}
|
||||
|
||||
func isCommonPassword(password string) bool {
|
||||
normalized := strings.ToLower(strings.TrimSpace(password))
|
||||
_, found := commonPasswords[normalized]
|
||||
return found
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type encryptedBlob struct {
|
||||
Nonce string `json:"nonce"`
|
||||
Ciphertext string `json:"ciphertext"`
|
||||
}
|
||||
|
||||
func loadEncryptedJSON(dir string, fileName string, key []byte, destination any) error {
|
||||
path := filepath.Join(dir, fileName)
|
||||
payload, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", fileName, err)
|
||||
}
|
||||
|
||||
var blob encryptedBlob
|
||||
if err := json.Unmarshal(payload, &blob); err != nil {
|
||||
return fmt.Errorf("parse %s envelope: %w", fileName, err)
|
||||
}
|
||||
|
||||
nonce, err := base64.StdEncoding.DecodeString(blob.Nonce)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode nonce: %w", err)
|
||||
}
|
||||
ciphertext, err := base64.StdEncoding.DecodeString(blob.Ciphertext)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode ciphertext: %w", err)
|
||||
}
|
||||
|
||||
plaintext, err := open(nonce, ciphertext, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(plaintext, destination); err != nil {
|
||||
return fmt.Errorf("parse %s: %w", fileName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveEncryptedJSON(dir string, fileName string, key []byte, value any) error {
|
||||
if err := ensureConfigDir(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
plaintext, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode %s: %w", fileName, err)
|
||||
}
|
||||
|
||||
nonce, ciphertext, err := seal(plaintext, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
blob := encryptedBlob{
|
||||
Nonce: base64.StdEncoding.EncodeToString(nonce),
|
||||
Ciphertext: base64.StdEncoding.EncodeToString(ciphertext),
|
||||
}
|
||||
payload, err := json.MarshalIndent(blob, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode %s envelope: %w", fileName, err)
|
||||
}
|
||||
payload = append(payload, '\n')
|
||||
|
||||
path := filepath.Join(dir, fileName)
|
||||
if err := os.WriteFile(path, payload, 0o600); err != nil {
|
||||
return fmt.Errorf("write %s: %w", fileName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"net"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// ListenHost returns the configured listen IP for binding, defaulting to 0.0.0.0.
|
||||
func ListenHost(dir string) string {
|
||||
payload, err := LoadSettings(dir)
|
||||
if err != nil {
|
||||
return "0.0.0.0"
|
||||
}
|
||||
network := EffectiveNetwork(payload.Network)
|
||||
host := network.ListenAddress
|
||||
if host == "" {
|
||||
return "0.0.0.0"
|
||||
}
|
||||
if host != "0.0.0.0" && host != "::" && host != "localhost" && net.ParseIP(host) == nil {
|
||||
return "0.0.0.0"
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// SettingsPath returns the path to settings.json in dir.
|
||||
func SettingsPath(dir string) string {
|
||||
return filepath.Join(dir, SettingsFileName)
|
||||
}
|
||||
@@ -12,8 +12,10 @@ const (
|
||||
ConfigDirEnvVar = "CLUSTERCANVAS_CONFIG_DIR"
|
||||
ConfigKeyEnvVar = "CLUSTERCANVAS_CONFIG_KEY"
|
||||
|
||||
SettingsFileName = "settings.json"
|
||||
SecretsFileName = "secrets.enc"
|
||||
SettingsFileName = "settings.json"
|
||||
SecretsFileName = "secrets.enc"
|
||||
PasswordsFileName = "passwords.enc"
|
||||
SessionsFileName = "sessions.enc"
|
||||
)
|
||||
|
||||
// ResolveDir returns the config directory using precedence:
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"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).
|
||||
func KeyFromEnv() ([]byte, error) {
|
||||
rawKey := os.Getenv(ConfigKeyEnvVar)
|
||||
@@ -24,67 +17,78 @@ func KeyFromEnv() ([]byte, error) {
|
||||
|
||||
// LoadSecrets decrypts secrets.enc from dir using key.
|
||||
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
|
||||
if err := json.Unmarshal(plaintext, &secrets); err != nil {
|
||||
return Secrets{}, fmt.Errorf("parse secrets: %w", err)
|
||||
if err := loadEncryptedJSON(dir, SecretsFileName, key, &secrets); err != nil {
|
||||
return Secrets{}, err
|
||||
}
|
||||
return secrets, nil
|
||||
}
|
||||
|
||||
// SaveSecrets encrypts secrets and writes secrets.enc to dir with mode 0600.
|
||||
func SaveSecrets(dir string, secrets Secrets, key []byte) error {
|
||||
if err := ensureConfigDir(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
plaintext, err := json.Marshal(secrets)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode secrets: %w", 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 secrets envelope: %w", err)
|
||||
}
|
||||
payload = append(payload, '\n')
|
||||
|
||||
path := filepath.Join(dir, SecretsFileName)
|
||||
if err := os.WriteFile(path, payload, 0o600); err != nil {
|
||||
return fmt.Errorf("write secrets: %w", err)
|
||||
}
|
||||
return nil
|
||||
return saveEncryptedJSON(dir, SecretsFileName, key, secrets)
|
||||
}
|
||||
|
||||
// LoadPasswords decrypts passwords.enc from dir using key.
|
||||
func LoadPasswords(dir string, key []byte) (PasswordStore, error) {
|
||||
var store PasswordStore
|
||||
if err := loadEncryptedJSON(dir, PasswordsFileName, key, &store); err != nil {
|
||||
return PasswordStore{}, err
|
||||
}
|
||||
if store.Users == nil {
|
||||
store.Users = []UserCredential{}
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// LoadPasswordsOrEmpty returns an empty store when passwords.enc is missing.
|
||||
func LoadPasswordsOrEmpty(dir string, key []byte) (PasswordStore, error) {
|
||||
store, err := LoadPasswords(dir, key)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return PasswordStore{Users: []UserCredential{}}, nil
|
||||
}
|
||||
return PasswordStore{}, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// SavePasswords encrypts the password store and writes passwords.enc.
|
||||
func SavePasswords(dir string, store PasswordStore, key []byte) error {
|
||||
if store.Users == nil {
|
||||
store.Users = []UserCredential{}
|
||||
}
|
||||
return saveEncryptedJSON(dir, PasswordsFileName, key, store)
|
||||
}
|
||||
|
||||
// LoadSessions decrypts sessions.enc from dir using key.
|
||||
func LoadSessions(dir string, key []byte) (SessionStore, error) {
|
||||
var store SessionStore
|
||||
if err := loadEncryptedJSON(dir, SessionsFileName, key, &store); err != nil {
|
||||
return SessionStore{}, err
|
||||
}
|
||||
if store.Sessions == nil {
|
||||
store.Sessions = []SessionRecord{}
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// LoadSessionsOrEmpty returns an empty store when sessions.enc is missing.
|
||||
func LoadSessionsOrEmpty(dir string, key []byte) (SessionStore, error) {
|
||||
store, err := LoadSessions(dir, key)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return SessionStore{Sessions: []SessionRecord{}}, nil
|
||||
}
|
||||
return SessionStore{}, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// SaveSessions encrypts the session store and writes sessions.enc.
|
||||
func SaveSessions(dir string, store SessionStore, key []byte) error {
|
||||
if store.Sessions == nil {
|
||||
store.Sessions = []SessionRecord{}
|
||||
}
|
||||
return saveEncryptedJSON(dir, SessionsFileName, key, store)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package settings
|
||||
|
||||
import "time"
|
||||
|
||||
// GroupScopeKind identifies what a group scopes to.
|
||||
type GroupScopeKind string
|
||||
|
||||
@@ -8,6 +10,10 @@ const (
|
||||
GroupScopeGroup GroupScopeKind = "group"
|
||||
)
|
||||
|
||||
// AdministratorsGroupName is the built-in admin group created at setup.
|
||||
// At least one user must remain in this group; the group itself cannot be deleted.
|
||||
const AdministratorsGroupName = "Administrators"
|
||||
|
||||
// Group configures a named permission set for a given scope target.
|
||||
//
|
||||
// Groups are persisted in settings.json and later used by authz middleware.
|
||||
@@ -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.
|
||||
type Settings struct {
|
||||
LogLevel string `json:"log_level"`
|
||||
Groups []Group `json:"groups"`
|
||||
Security SecuritySettings `json:"security"`
|
||||
SetupCompleted bool `json:"setup_completed"`
|
||||
LogLevel string `json:"log_level"`
|
||||
Groups []Group `json:"groups"`
|
||||
Security SecuritySettings `json:"security"`
|
||||
Network NetworkSettings `json:"network"`
|
||||
}
|
||||
|
||||
// Secrets holds sensitive configuration encrypted at rest.
|
||||
type Secrets struct {
|
||||
// Placeholder until Proxmox/API secrets exist.
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package websrv
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// NewHandler serves SPA files from webDir and reverse-proxies /api and /health to apiURL.
|
||||
func NewHandler(webDir string, apiURL string) (http.Handler, error) {
|
||||
absoluteWebDir, err := filepath.Abs(webDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve webdir: %w", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(absoluteWebDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("webdir: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, fmt.Errorf("webdir is not a directory: %s", absoluteWebDir)
|
||||
}
|
||||
|
||||
parsedAPIURL, err := url.Parse(apiURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse API_URL: %w", err)
|
||||
}
|
||||
if parsedAPIURL.Scheme == "" || parsedAPIURL.Host == "" {
|
||||
return nil, fmt.Errorf("API_URL must include scheme and host: %q", apiURL)
|
||||
}
|
||||
|
||||
apiProxy := httputil.NewSingleHostReverseProxy(parsedAPIURL)
|
||||
defaultDirector := apiProxy.Director
|
||||
apiProxy.Director = func(request *http.Request) {
|
||||
originalHost := request.Host
|
||||
clientIP := clientIPFromRequest(request)
|
||||
defaultDirector(request)
|
||||
// Preserve the browser-facing host so the API can enforce public_hostname.
|
||||
if request.Header.Get("X-Forwarded-Host") == "" && originalHost != "" {
|
||||
request.Header.Set("X-Forwarded-Host", originalHost)
|
||||
}
|
||||
if request.Header.Get("X-Forwarded-Proto") == "" {
|
||||
if request.TLS != nil {
|
||||
request.Header.Set("X-Forwarded-Proto", "https")
|
||||
} else {
|
||||
request.Header.Set("X-Forwarded-Proto", "http")
|
||||
}
|
||||
}
|
||||
if clientIP != "" {
|
||||
prior := strings.TrimSpace(request.Header.Get("X-Forwarded-For"))
|
||||
if prior == "" {
|
||||
request.Header.Set("X-Forwarded-For", clientIP)
|
||||
} else {
|
||||
request.Header.Set("X-Forwarded-For", prior+", "+clientIP)
|
||||
}
|
||||
}
|
||||
}
|
||||
fileServer := http.FileServer(http.Dir(absoluteWebDir))
|
||||
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
requestPath := request.URL.Path
|
||||
if requestPath == "/health" || strings.HasPrefix(requestPath, "/api/") || requestPath == "/api" {
|
||||
apiProxy.ServeHTTP(writer, request)
|
||||
return
|
||||
}
|
||||
|
||||
serveSPA(writer, request, absoluteWebDir, fileServer)
|
||||
}), nil
|
||||
}
|
||||
|
||||
func clientIPFromRequest(request *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(request.RemoteAddr)
|
||||
if err != nil {
|
||||
return request.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
func serveSPA(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
webDir string,
|
||||
fileServer http.Handler,
|
||||
) {
|
||||
cleanedPath := path.Clean("/" + request.URL.Path)
|
||||
if cleanedPath == "/" {
|
||||
http.ServeFile(writer, request, filepath.Join(webDir, "index.html"))
|
||||
return
|
||||
}
|
||||
|
||||
candidatePath := filepath.Join(webDir, filepath.FromSlash(cleanedPath))
|
||||
if !isWithinDir(webDir, candidatePath) {
|
||||
http.NotFound(writer, request)
|
||||
return
|
||||
}
|
||||
|
||||
fileInfo, err := os.Stat(candidatePath)
|
||||
if err != nil || fileInfo.IsDir() {
|
||||
http.ServeFile(writer, request, filepath.Join(webDir, "index.html"))
|
||||
return
|
||||
}
|
||||
|
||||
fileServer.ServeHTTP(writer, request)
|
||||
}
|
||||
|
||||
func isWithinDir(rootDir string, candidatePath string) bool {
|
||||
relativePath, err := filepath.Rel(rootDir, candidatePath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return relativePath != ".." && !strings.HasPrefix(relativePath, ".."+string(os.PathSeparator))
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package websrv
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewHandlerRejectsMissingWebDir(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := NewHandler(filepath.Join(t.TempDir(), "missing"), "http://127.0.0.1:8080")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing webdir")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewHandlerRejectsInvalidAPIURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
webDir := t.TempDir()
|
||||
_, err := NewHandler(webDir, "not-a-url")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid API_URL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPAServesIndexAndAssets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
webDir := t.TempDir()
|
||||
mustWriteFile(t, filepath.Join(webDir, "index.html"), "<html>home</html>")
|
||||
mustWriteFile(t, filepath.Join(webDir, "assets", "app.js"), "console.log(1)")
|
||||
|
||||
api := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(api.Close)
|
||||
|
||||
handler, err := NewHandler(webDir, api.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("NewHandler: %v", err)
|
||||
}
|
||||
|
||||
indexRecorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(indexRecorder, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
if indexRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("GET / status = %d", indexRecorder.Code)
|
||||
}
|
||||
if !strings.Contains(indexRecorder.Body.String(), "home") {
|
||||
t.Fatalf("GET / body = %q", indexRecorder.Body.String())
|
||||
}
|
||||
|
||||
assetRecorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(assetRecorder, httptest.NewRequest(http.MethodGet, "/assets/app.js", nil))
|
||||
if assetRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("GET /assets/app.js status = %d", assetRecorder.Code)
|
||||
}
|
||||
if assetRecorder.Body.String() != "console.log(1)" {
|
||||
t.Fatalf("asset body = %q", assetRecorder.Body.String())
|
||||
}
|
||||
|
||||
spaRecorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(spaRecorder, httptest.NewRequest(http.MethodGet, "/settings", nil))
|
||||
if spaRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("GET /settings status = %d", spaRecorder.Code)
|
||||
}
|
||||
if !strings.Contains(spaRecorder.Body.String(), "home") {
|
||||
t.Fatalf("SPA fallback body = %q", spaRecorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyForwardsAPIAndHealth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
webDir := t.TempDir()
|
||||
mustWriteFile(t, filepath.Join(webDir, "index.html"), "<html>home</html>")
|
||||
|
||||
var seenPaths []string
|
||||
var seenForwardedHost string
|
||||
api := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
seenPaths = append(seenPaths, request.URL.Path)
|
||||
seenForwardedHost = request.Header.Get("X-Forwarded-Host")
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(writer, `{"ok":true}`)
|
||||
}))
|
||||
t.Cleanup(api.Close)
|
||||
|
||||
handler, err := NewHandler(webDir, api.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("NewHandler: %v", err)
|
||||
}
|
||||
|
||||
healthRecorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(healthRecorder, httptest.NewRequest(http.MethodGet, "/health", nil))
|
||||
if healthRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("GET /health status = %d", healthRecorder.Code)
|
||||
}
|
||||
|
||||
apiRequest := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
|
||||
apiRequest.Host = "admin.example.test:5173"
|
||||
apiRecorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(apiRecorder, apiRequest)
|
||||
if apiRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("GET /api/v1/status status = %d", apiRecorder.Code)
|
||||
}
|
||||
|
||||
if len(seenPaths) != 2 || seenPaths[0] != "/health" || seenPaths[1] != "/api/v1/status" {
|
||||
t.Fatalf("proxied paths = %#v", seenPaths)
|
||||
}
|
||||
if seenForwardedHost != "admin.example.test:5173" {
|
||||
t.Fatalf("X-Forwarded-Host = %q, want browser host", seenForwardedHost)
|
||||
}
|
||||
}
|
||||
|
||||
func mustWriteFile(t *testing.T, path string, contents string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
}
|
||||
Generated
+281
-4
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"name": "webui",
|
||||
"version": "0.0.0",
|
||||
"version": "0.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "webui",
|
||||
"version": "0.0.0",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7"
|
||||
},
|
||||
@@ -16,6 +17,7 @@
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
@@ -1031,6 +1033,15 @@
|
||||
"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": {
|
||||
"version": "19.2.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
||||
@@ -1184,8 +1195,6 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@@ -1230,6 +1239,14 @@
|
||||
"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": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
@@ -1239,6 +1256,32 @@
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"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_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": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
@@ -1307,6 +1358,11 @@
|
||||
"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": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
@@ -1314,6 +1370,11 @@
|
||||
"dev": 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": {
|
||||
"version": "8.0.0",
|
||||
"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": {
|
||||
"version": "2.3.3",
|
||||
"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_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": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
|
||||
@@ -1402,6 +1483,14 @@
|
||||
"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": {
|
||||
"version": "1.0.1",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "11.5.2",
|
||||
"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": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
|
||||
@@ -1838,6 +1971,14 @@
|
||||
"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": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
@@ -1862,6 +2003,14 @@
|
||||
"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": {
|
||||
"version": "8.5.19",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz",
|
||||
@@ -1914,6 +2063,22 @@
|
||||
"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": {
|
||||
"version": "19.2.7",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
|
||||
@@ -1953,6 +2118,14 @@
|
||||
"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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
@@ -1962,6 +2135,11 @@
|
||||
"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": {
|
||||
"version": "1.1.5",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
@@ -2039,6 +2222,30 @@
|
||||
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
|
||||
"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": {
|
||||
"version": "3.0.0",
|
||||
"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_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": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
@@ -2400,6 +2612,33 @@
|
||||
"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": {
|
||||
"version": "5.0.0",
|
||||
"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",
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7"
|
||||
},
|
||||
@@ -20,6 +21,7 @@
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
|
||||
@@ -51,6 +51,15 @@
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -311,6 +320,19 @@
|
||||
font-weight: 650;
|
||||
color: var(--ctp-text);
|
||||
margin-bottom: 0.1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.user-badge {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-subtext0);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
}
|
||||
|
||||
.groups-li-meta {
|
||||
@@ -331,6 +353,16 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.groups-delete:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.groups-delete:disabled:hover {
|
||||
background: transparent;
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.groups-delete:hover {
|
||||
background: var(--ctp-mauve-hover);
|
||||
color: var(--ctp-mauve);
|
||||
@@ -468,3 +500,102 @@
|
||||
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;
|
||||
}
|
||||
|
||||
+335
-14
@@ -10,6 +10,24 @@ function stubFetchOk() {
|
||||
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'],
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
if (url.includes('/health')) {
|
||||
return Promise.resolve({
|
||||
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({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
@@ -95,7 +159,7 @@ describe('App', () => {
|
||||
render(<App />)
|
||||
|
||||
expect(
|
||||
screen.getByRole('heading', { name: 'Cluster Canvas' }),
|
||||
await screen.findByRole('heading', { name: 'Cluster Canvas' }),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(`Frontend Version ${__FRONTEND_VERSION__}`),
|
||||
@@ -103,14 +167,14 @@ describe('App', () => {
|
||||
expect(await screen.findByText('Backend Version 0.1.0')).toBeInTheDocument()
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Overview' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'VMs' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Virtual Machines' })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Containers' }),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Configuration' }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Profile' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Admin' })).toBeInTheDocument()
|
||||
|
||||
expect(
|
||||
screen.getByRole('heading', { name: 'Overview', level: 2 }),
|
||||
@@ -124,10 +188,12 @@ describe('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(
|
||||
screen.getByRole('heading', { name: 'Profile', level: 2 }),
|
||||
screen.getByRole('heading', { name: 'Admin', level: 2 }),
|
||||
).toBeInTheDocument()
|
||||
expect(window.location.pathname).toBe('/profile')
|
||||
expect(
|
||||
@@ -142,9 +208,9 @@ describe('App', () => {
|
||||
render(<App />)
|
||||
|
||||
expect(
|
||||
screen.getByRole('heading', { name: 'Profile', level: 2 }),
|
||||
await screen.findByRole('heading', { name: 'Admin', level: 2 }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Profile' })).toHaveAttribute(
|
||||
expect(screen.getByRole('button', { name: 'Admin' })).toHaveAttribute(
|
||||
'aria-current',
|
||||
'page',
|
||||
)
|
||||
@@ -156,12 +222,20 @@ describe('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(
|
||||
screen.getByRole('heading', { name: 'VMs', level: 2 }),
|
||||
screen.getByRole('heading', { name: 'Virtual Machines', level: 2 }),
|
||||
).toBeInTheDocument()
|
||||
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' }))
|
||||
expect(
|
||||
screen.getByRole('heading', { name: 'Configuration', level: 2 }),
|
||||
@@ -180,6 +254,8 @@ describe('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('tab', { name: 'Groups' }))
|
||||
|
||||
@@ -197,7 +273,7 @@ describe('App', () => {
|
||||
render(<App />)
|
||||
|
||||
expect(
|
||||
screen.getByRole('heading', { name: 'Configuration', level: 2 }),
|
||||
await screen.findByRole('heading', { name: 'Configuration', level: 2 }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Groups' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
@@ -212,6 +288,8 @@ describe('App', () => {
|
||||
|
||||
render(<App />)
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Cluster Canvas' })).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Configuration' }))
|
||||
|
||||
for (const label of [
|
||||
@@ -277,6 +355,29 @@ describe('App', () => {
|
||||
expect(screen.getByLabelText('Re-auth grace period (minutes)')).toBeDisabled()
|
||||
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' }))
|
||||
expect(screen.getByRole('tab', { name: 'Integrations' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
@@ -285,12 +386,202 @@ describe('App', () => {
|
||||
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 () => {
|
||||
const user = userEvent.setup()
|
||||
stubFetchOk()
|
||||
|
||||
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: 'Security' }))
|
||||
|
||||
@@ -311,9 +602,29 @@ describe('App', () => {
|
||||
it('shows unavailable when backend status fetch fails', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 503,
|
||||
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,
|
||||
status: 503,
|
||||
json: async () => ({}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -324,12 +635,15 @@ describe('App', () => {
|
||||
).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()
|
||||
stubMatchMedia(false)
|
||||
|
||||
render(<App />)
|
||||
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: 'Cluster Canvas' }),
|
||||
).toBeInTheDocument()
|
||||
expect(document.documentElement.dataset.theme).toBe('latte')
|
||||
})
|
||||
|
||||
@@ -340,6 +654,8 @@ describe('App', () => {
|
||||
|
||||
render(<App />)
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Cluster Canvas' })).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Configuration' }))
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
@@ -368,6 +684,9 @@ describe('App', () => {
|
||||
|
||||
render(<App />)
|
||||
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: 'Cluster Canvas' }),
|
||||
).toBeInTheDocument()
|
||||
expect(document.documentElement.dataset.theme).toBe('mocha')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Configuration' }))
|
||||
@@ -394,6 +713,8 @@ describe('App', () => {
|
||||
|
||||
render(<App />)
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Cluster Canvas' })).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Configuration' }))
|
||||
await user.selectOptions(screen.getByLabelText('Theme'), 'light')
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
+468
-6
@@ -1,16 +1,27 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
deleteGroup,
|
||||
deleteUser,
|
||||
DEFAULT_NETWORK_SETTINGS,
|
||||
DEFAULT_SECURITY_SETTINGS,
|
||||
fetchGroups,
|
||||
fetchMe,
|
||||
fetchNetwork,
|
||||
fetchSecurity,
|
||||
fetchSetupStatus,
|
||||
fetchStatus,
|
||||
fetchUsers,
|
||||
type AccessMode,
|
||||
type Group,
|
||||
type GroupScopeKind,
|
||||
type NetworkSettings,
|
||||
type SecuritySettings,
|
||||
type User,
|
||||
saveNetwork,
|
||||
saveSecurity,
|
||||
upsertGroup,
|
||||
} from './api/client'
|
||||
import { LoginPage } from './LoginPage'
|
||||
import {
|
||||
navigateTo,
|
||||
parseLocation,
|
||||
@@ -19,6 +30,11 @@ import {
|
||||
type ConfigTabId,
|
||||
type SectionId,
|
||||
} from './navigation'
|
||||
import {
|
||||
validateHostname,
|
||||
validateListenAddress,
|
||||
} from './passwordPolicy'
|
||||
import { SetupWizard } from './SetupWizard'
|
||||
import {
|
||||
applyThemeFromPreference,
|
||||
getSystemPrefersDark,
|
||||
@@ -30,16 +46,20 @@ import './App.css'
|
||||
|
||||
const NAV_ITEMS: ReadonlyArray<{ id: SectionId; label: string }> = [
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'vms', label: 'VMs' },
|
||||
{ id: 'containers', label: 'Containers' },
|
||||
{ id: 'docker', label: 'Docker' },
|
||||
{ id: 'vms', label: 'Virtual Machines' },
|
||||
]
|
||||
|
||||
const SECTION_TITLES: Record<SectionId, string> = {
|
||||
overview: 'Overview',
|
||||
vms: 'VMs',
|
||||
vms: 'Virtual Machines',
|
||||
docker: 'Docker',
|
||||
containers: 'Containers',
|
||||
profile: 'Profile',
|
||||
configuration: 'Configuration',
|
||||
setup: 'Setup',
|
||||
login: 'Sign in',
|
||||
}
|
||||
|
||||
const CONFIG_TABS: ReadonlyArray<{ id: ConfigTabId; label: string }> = [
|
||||
@@ -64,6 +84,16 @@ const DEFAULT_PERMISSIONS = [
|
||||
'roles.manage',
|
||||
] 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() {
|
||||
return (
|
||||
<svg
|
||||
@@ -292,6 +322,12 @@ function GroupsConfigTab() {
|
||||
<button
|
||||
type="button"
|
||||
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)}
|
||||
>
|
||||
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 {
|
||||
if (
|
||||
!Number.isInteger(security.idle_timeout_minutes) ||
|
||||
@@ -672,10 +1014,18 @@ function ConfigTabPanelContent({
|
||||
return <GroupsConfigTab />
|
||||
}
|
||||
|
||||
if (activeConfigTab === 'users') {
|
||||
return <UsersConfigTab />
|
||||
}
|
||||
|
||||
if (activeConfigTab === 'security') {
|
||||
return <SecurityConfigTab />
|
||||
}
|
||||
|
||||
if (activeConfigTab === 'network') {
|
||||
return <NetworkConfigTab />
|
||||
}
|
||||
|
||||
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() {
|
||||
const initialLocation = parseLocation(window.location.pathname)
|
||||
const [activeSection, setActiveSection] = useState<SectionId>(
|
||||
@@ -767,9 +1123,11 @@ function App() {
|
||||
const [activeConfigTab, setActiveConfigTab] = useState<ConfigTabId>(
|
||||
initialLocation.configTab,
|
||||
)
|
||||
// Stub until auth provides the logged-in display name.
|
||||
const [profileDisplayName] = useState<string | null>(null)
|
||||
const [profileDisplayName, setProfileDisplayName] = useState<string | null>(
|
||||
null,
|
||||
)
|
||||
const [backendVersion, setBackendVersion] = useState('…')
|
||||
const [bootState, setBootState] = useState<BootState>({ kind: 'loading' })
|
||||
const { themePreference, updateThemePreference } = useThemePreference()
|
||||
|
||||
const profileLabel = profileDisplayName ?? 'Profile'
|
||||
@@ -785,6 +1143,58 @@ function App() {
|
||||
useEffect(() => {
|
||||
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() {
|
||||
try {
|
||||
const status = await fetchStatus()
|
||||
@@ -803,9 +1213,12 @@ function App() {
|
||||
return () => {
|
||||
isCancelled = true
|
||||
}
|
||||
}, [])
|
||||
}, [bootState.kind])
|
||||
|
||||
function goToSection(section: SectionId) {
|
||||
if (section === 'setup' || section === 'login') {
|
||||
return
|
||||
}
|
||||
const nextTab = section === 'configuration' ? activeConfigTab : 'overview'
|
||||
setActiveSection(section)
|
||||
if (section !== 'configuration') {
|
||||
@@ -820,6 +1233,39 @@ function App() {
|
||||
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 (
|
||||
<div className="app-shell">
|
||||
<aside className="sidebar" aria-label="Main menu">
|
||||
@@ -830,7 +1276,23 @@ function App() {
|
||||
</div>
|
||||
|
||||
<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
|
||||
key={item.id}
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { login } from './api/client'
|
||||
|
||||
type LoginPageProps = {
|
||||
onLoggedIn: (username: string) => void
|
||||
}
|
||||
|
||||
export function LoginPage({ onLoggedIn }: LoginPageProps) {
|
||||
const [username, setUsername] = useState('Admin')
|
||||
const [password, setPassword] = useState('')
|
||||
const [totpCode, setTotpCode] = useState('')
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
setErrorMessage(null)
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const me = await login(username.trim(), password, totpCode.trim())
|
||||
onLoggedIn(me.username)
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Login failed')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="wizard-shell">
|
||||
<form className="wizard-card" onSubmit={(event) => void handleSubmit(event)}>
|
||||
<p className="wizard-brand">Cluster Canvas</p>
|
||||
<h1 className="wizard-title">Sign in</h1>
|
||||
<p className="config-hint">
|
||||
Use the administrator account created during setup.
|
||||
</p>
|
||||
|
||||
{errorMessage ? (
|
||||
<p className="wizard-error" role="alert">
|
||||
{errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="config-field">
|
||||
<label htmlFor="login-username">Login name</label>
|
||||
<input
|
||||
id="login-username"
|
||||
className="config-input"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
<div className="config-field">
|
||||
<label htmlFor="login-password">Password</label>
|
||||
<input
|
||||
id="login-password"
|
||||
className="config-input"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="config-field">
|
||||
<label htmlFor="login-totp">TOTP code (if enabled)</label>
|
||||
<input
|
||||
id="login-totp"
|
||||
className="config-input"
|
||||
value={totpCode}
|
||||
onChange={(event) => setTotpCode(event.target.value)}
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="wizard-actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="groups-save"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
)
|
||||
}
|
||||
+179
-18
@@ -1,11 +1,15 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
deleteGroup,
|
||||
deleteUser,
|
||||
fetchGroups,
|
||||
fetchHealth,
|
||||
fetchNetwork,
|
||||
fetchSecurity,
|
||||
fetchStatus,
|
||||
fetchUsers,
|
||||
getApiBaseUrl,
|
||||
saveNetwork,
|
||||
saveSecurity,
|
||||
upsertGroup,
|
||||
} from './client'
|
||||
@@ -14,6 +18,15 @@ describe('getApiBaseUrl', () => {
|
||||
it('defaults to localhost:8080 when unset', () => {
|
||||
expect(getApiBaseUrl()).toBe('http://localhost:8080')
|
||||
})
|
||||
|
||||
it('keeps an empty string for same-origin requests', async () => {
|
||||
vi.resetModules()
|
||||
vi.stubEnv('VITE_API_BASE_URL', '')
|
||||
const clientModule = await import('./client')
|
||||
expect(clientModule.getApiBaseUrl()).toBe('')
|
||||
vi.unstubAllEnvs()
|
||||
vi.resetModules()
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchHealth', () => {
|
||||
@@ -29,7 +42,10 @@ describe('fetchHealth', () => {
|
||||
|
||||
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' })
|
||||
})
|
||||
|
||||
@@ -61,6 +77,7 @@ describe('fetchStatus', () => {
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8080/api/v1/status',
|
||||
expect.objectContaining({ credentials: 'include' }),
|
||||
)
|
||||
expect(status).toEqual({ service: 'clustercanvas', version: '0.1.0' })
|
||||
})
|
||||
@@ -100,7 +117,10 @@ describe('fetchGroups', () => {
|
||||
|
||||
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([
|
||||
{
|
||||
name: 'Admins',
|
||||
@@ -145,18 +165,21 @@ describe('upsertGroup', () => {
|
||||
fetchMock,
|
||||
)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('http://localhost:8080/api/v1/groups', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
group: {
|
||||
name: 'Admins',
|
||||
scope_kind: 'node',
|
||||
scope_name: 'node-1',
|
||||
permissions: ['nodes.read'],
|
||||
},
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8080/api/v1/groups',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
group: {
|
||||
name: 'Admins',
|
||||
scope_kind: 'node',
|
||||
scope_name: 'node-1',
|
||||
permissions: ['nodes.read'],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('throws when the response is not ok', async () => {
|
||||
@@ -195,7 +218,7 @@ describe('deleteGroup', () => {
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'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(
|
||||
'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(
|
||||
'http://localhost:8080/api/v1/security',
|
||||
expect.objectContaining({ credentials: 'include' }),
|
||||
)
|
||||
expect(payload.security).toEqual(security)
|
||||
})
|
||||
@@ -273,11 +377,11 @@ describe('saveSecurity', () => {
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8080/api/v1/security',
|
||||
{
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ security }),
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -302,3 +406,60 @@ describe('saveSecurity', () => {
|
||||
).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 }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
+309
-18
@@ -20,6 +20,22 @@ export type GroupsResponse = {
|
||||
groups: ReadonlyArray<Group>
|
||||
}
|
||||
|
||||
export type User = {
|
||||
id: string
|
||||
username: string
|
||||
enabled: boolean
|
||||
totp_confirmed: boolean
|
||||
group_names: ReadonlyArray<string>
|
||||
created_at: string
|
||||
password_changed_at: string
|
||||
is_admin: boolean
|
||||
can_delete: boolean
|
||||
}
|
||||
|
||||
export type UsersResponse = {
|
||||
users: ReadonlyArray<User>
|
||||
}
|
||||
|
||||
export type SecuritySettings = {
|
||||
idle_timeout_minutes: number
|
||||
session_lifetime_hours: number
|
||||
@@ -32,6 +48,50 @@ export type SecurityResponse = {
|
||||
security: SecuritySettings
|
||||
}
|
||||
|
||||
export type NetworkResponse = {
|
||||
network: NetworkSettings
|
||||
}
|
||||
|
||||
export type AccessMode = 'open' | 'whitelist' | 'blacklist'
|
||||
|
||||
export type NetworkSettings = {
|
||||
listen_address: string
|
||||
public_hostname: string
|
||||
access_mode: AccessMode
|
||||
rules: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
export type SetupStatusResponse = {
|
||||
completed: boolean
|
||||
client_ip: string
|
||||
}
|
||||
|
||||
export type SetupTOTPBeginResponse = {
|
||||
secret: string
|
||||
otpauth_url: string
|
||||
}
|
||||
|
||||
export type SetupCompleteRequest = {
|
||||
username: string
|
||||
password: string
|
||||
totp_secret: string
|
||||
totp_confirmed: boolean
|
||||
network: NetworkSettings
|
||||
security: SecuritySettings
|
||||
}
|
||||
|
||||
export type MeResponse = {
|
||||
user_id: string
|
||||
username: string
|
||||
groups: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
export type NetworkPreviewResponse = {
|
||||
would_lock_out: boolean
|
||||
reason?: string
|
||||
client_ip: string
|
||||
}
|
||||
|
||||
export const DEFAULT_SECURITY_SETTINGS: SecuritySettings = {
|
||||
idle_timeout_minutes: 30,
|
||||
session_lifetime_hours: 24,
|
||||
@@ -40,14 +100,54 @@ export const DEFAULT_SECURITY_SETTINGS: SecuritySettings = {
|
||||
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 {
|
||||
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(
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<HealthResponse> {
|
||||
const response = await fetchImpl(`${getApiBaseUrl()}/health`)
|
||||
const response = await apiFetch('/health', {}, fetchImpl)
|
||||
if (!response.ok) {
|
||||
throw new Error(`health check failed: ${response.status}`)
|
||||
}
|
||||
@@ -57,7 +157,7 @@ export async function fetchHealth(
|
||||
export async function fetchStatus(
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<StatusResponse> {
|
||||
const response = await fetchImpl(`${getApiBaseUrl()}/api/v1/status`)
|
||||
const response = await apiFetch('/api/v1/status', {}, fetchImpl)
|
||||
if (!response.ok) {
|
||||
throw new Error(`status check failed: ${response.status}`)
|
||||
}
|
||||
@@ -67,7 +167,7 @@ export async function fetchStatus(
|
||||
export async function fetchGroups(
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<GroupsResponse> {
|
||||
const response = await fetchImpl(`${getApiBaseUrl()}/api/v1/groups`)
|
||||
const response = await apiFetch('/api/v1/groups', {}, fetchImpl)
|
||||
if (!response.ok) {
|
||||
throw new Error(`groups fetch failed: ${response.status}`)
|
||||
}
|
||||
@@ -78,11 +178,14 @@ export async function upsertGroup(
|
||||
group: Group,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<GroupsResponse> {
|
||||
const response = await fetchImpl(`${getApiBaseUrl()}/api/v1/groups`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ group }),
|
||||
})
|
||||
const response = await apiFetch(
|
||||
'/api/v1/groups',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ group }),
|
||||
},
|
||||
fetchImpl,
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`groups upsert failed: ${response.status}`)
|
||||
@@ -95,22 +198,48 @@ export async function deleteGroup(
|
||||
name: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<GroupsResponse> {
|
||||
const response = await fetchImpl(
|
||||
`${getApiBaseUrl()}/api/v1/groups?name=${encodeURIComponent(name)}`,
|
||||
const response = await apiFetch(
|
||||
`/api/v1/groups?name=${encodeURIComponent(name)}`,
|
||||
{ method: 'DELETE' },
|
||||
fetchImpl,
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`groups delete failed: ${response.status}`)
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
|
||||
return (await response.json()) as GroupsResponse
|
||||
}
|
||||
|
||||
export async function fetchUsers(
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<UsersResponse> {
|
||||
const response = await apiFetch('/api/v1/users', {}, fetchImpl)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as UsersResponse
|
||||
}
|
||||
|
||||
export async function 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(
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<SecurityResponse> {
|
||||
const response = await fetchImpl(`${getApiBaseUrl()}/api/v1/security`)
|
||||
const response = await apiFetch('/api/v1/security', {}, fetchImpl)
|
||||
if (!response.ok) {
|
||||
throw new Error(`security fetch failed: ${response.status}`)
|
||||
}
|
||||
@@ -121,11 +250,14 @@ export async function saveSecurity(
|
||||
security: SecuritySettings,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<SecurityResponse> {
|
||||
const response = await fetchImpl(`${getApiBaseUrl()}/api/v1/security`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ security }),
|
||||
})
|
||||
const response = await apiFetch(
|
||||
'/api/v1/security',
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ security }),
|
||||
},
|
||||
fetchImpl,
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`security save failed: ${response.status}`)
|
||||
@@ -133,3 +265,162 @@ export async function saveSecurity(
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -5,8 +5,11 @@ describe('pathFor', () => {
|
||||
it('maps sections and config tabs to paths', () => {
|
||||
expect(pathFor('overview')).toBe('/')
|
||||
expect(pathFor('vms')).toBe('/vms')
|
||||
expect(pathFor('docker')).toBe('/docker')
|
||||
expect(pathFor('containers')).toBe('/containers')
|
||||
expect(pathFor('profile')).toBe('/profile')
|
||||
expect(pathFor('setup')).toBe('/setup')
|
||||
expect(pathFor('login')).toBe('/login')
|
||||
expect(pathFor('configuration')).toBe('/configuration')
|
||||
expect(pathFor('configuration', 'overview')).toBe('/configuration')
|
||||
expect(pathFor('configuration', 'groups')).toBe('/configuration/groups')
|
||||
@@ -19,8 +22,11 @@ describe('parseLocation', () => {
|
||||
for (const path of [
|
||||
'/',
|
||||
'/vms',
|
||||
'/docker',
|
||||
'/containers',
|
||||
'/profile',
|
||||
'/setup',
|
||||
'/login',
|
||||
'/configuration',
|
||||
'/configuration/users',
|
||||
'/configuration/roles',
|
||||
@@ -48,6 +54,10 @@ describe('parseLocation', () => {
|
||||
section: 'overview',
|
||||
configTab: 'overview',
|
||||
})
|
||||
expect(parseLocation('/docker/extra')).toEqual({
|
||||
section: 'overview',
|
||||
configTab: 'overview',
|
||||
})
|
||||
})
|
||||
|
||||
it('treats trailing slashes as the same path', () => {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
export type SectionId =
|
||||
| 'overview'
|
||||
| 'vms'
|
||||
| 'docker'
|
||||
| 'containers'
|
||||
| 'profile'
|
||||
| 'configuration'
|
||||
| 'setup'
|
||||
| 'login'
|
||||
|
||||
export type ConfigTabId =
|
||||
| 'overview'
|
||||
@@ -51,10 +54,22 @@ export function parseLocation(pathname: string): AppLocation {
|
||||
|
||||
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) {
|
||||
return { section: 'vms', configTab: 'overview' }
|
||||
}
|
||||
|
||||
if (first === 'docker' && segments.length === 1) {
|
||||
return { section: 'docker', configTab: 'overview' }
|
||||
}
|
||||
|
||||
if (first === 'containers' && segments.length === 1) {
|
||||
return { section: 'containers', configTab: 'overview' }
|
||||
}
|
||||
@@ -83,9 +98,18 @@ export function pathFor(
|
||||
if (section === 'overview') {
|
||||
return '/'
|
||||
}
|
||||
if (section === 'setup') {
|
||||
return '/setup'
|
||||
}
|
||||
if (section === 'login') {
|
||||
return '/login'
|
||||
}
|
||||
if (section === 'vms') {
|
||||
return '/vms'
|
||||
}
|
||||
if (section === 'docker') {
|
||||
return '/docker'
|
||||
}
|
||||
if (section === 'containers') {
|
||||
return '/containers'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { validateHostname, validatePassword } from './passwordPolicy'
|
||||
|
||||
describe('validatePassword', () => {
|
||||
it('accepts long passwords and passphrases', () => {
|
||||
expect(validatePassword('Tr0ub4dor&3-extra!', 'Admin')).toBeNull()
|
||||
expect(
|
||||
validatePassword('apple banana cherry date elderberry', 'Admin'),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects weak values', () => {
|
||||
expect(validatePassword('short', 'Admin')).not.toBeNull()
|
||||
expect(validatePassword('Admin', 'Admin')).not.toBeNull()
|
||||
expect(validatePassword('password', 'Admin')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateHostname', () => {
|
||||
it('accepts DNS names and empty', () => {
|
||||
expect(validateHostname('')).toBeNull()
|
||||
expect(validateHostname('admin.mydomain.ext')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects schemes and paths', () => {
|
||||
expect(validateHostname('https://admin.example.com')).not.toBeNull()
|
||||
expect(validateHostname('admin.example.com/path')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
const COMMON_PASSWORDS = new Set([
|
||||
'password',
|
||||
'password123',
|
||||
'password1234',
|
||||
'12345678901234',
|
||||
'qwertyuiopasdf',
|
||||
'admin',
|
||||
'adminadmin',
|
||||
'letmein',
|
||||
'welcome',
|
||||
'welcome123',
|
||||
'changeme',
|
||||
'changeme123',
|
||||
'cluster canvas',
|
||||
'clustercanvas',
|
||||
'correct horse battery staple',
|
||||
])
|
||||
|
||||
const HOSTNAME_PATTERN =
|
||||
/^(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}|localhost)$/i
|
||||
|
||||
export function validatePassword(
|
||||
password: string,
|
||||
username: string,
|
||||
): string | null {
|
||||
if (password.length > 256) {
|
||||
return 'Password must be at most 256 characters'
|
||||
}
|
||||
for (const character of password) {
|
||||
const code = character.charCodeAt(0)
|
||||
if (code < 32 || code === 127) {
|
||||
return 'Password may only contain printable characters'
|
||||
}
|
||||
}
|
||||
if (password.trim().toLowerCase() === username.trim().toLowerCase()) {
|
||||
return 'Password cannot match the login name'
|
||||
}
|
||||
if (COMMON_PASSWORDS.has(password.trim().toLowerCase())) {
|
||||
return 'Password is too common'
|
||||
}
|
||||
const wordCount = password.trim().split(/\s+/).filter(Boolean).length
|
||||
if (password.length >= 14 || wordCount >= 5) {
|
||||
return null
|
||||
}
|
||||
return 'Password must be at least 14 characters or at least 5 words'
|
||||
}
|
||||
|
||||
export function validateHostname(hostname: string): string | null {
|
||||
const trimmed = hostname.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
if (trimmed.includes('/') || trimmed.includes(':') || trimmed.includes(' ')) {
|
||||
return 'Hostname must be a bare DNS name without scheme, port, or path'
|
||||
}
|
||||
if (!HOSTNAME_PATTERN.test(trimmed)) {
|
||||
return 'Hostname is not a valid DNS name'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function validateListenAddress(address: string): string | null {
|
||||
const trimmed = address.trim()
|
||||
if (!trimmed) {
|
||||
return 'Listen address is required'
|
||||
}
|
||||
if (
|
||||
trimmed === '0.0.0.0' ||
|
||||
trimmed === '::' ||
|
||||
trimmed === 'localhost' ||
|
||||
/^\d{1,3}(?:\.\d{1,3}){3}$/.test(trimmed)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return 'Listen address must be an IP address or 0.0.0.0'
|
||||
}
|
||||
Reference in New Issue
Block a user