From 2605c3b3466e52007bc5e50487f44ac4acd80de2 Mon Sep 17 00:00:00 2001 From: squid Date: Sat, 18 Jul 2026 15:18:01 +0200 Subject: [PATCH] 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. --- .gitignore | 1 + Makefile | 48 +- README.md | 64 +- remotedev.mk.example | 12 + service/cmd/server/main.go | 6 +- service/cmd/webui/main.go | 73 +++ service/go.mod | 12 +- service/go.sum | 15 + service/internal/api/groups_handlers.go | 22 +- service/internal/api/groups_handlers_test.go | 84 ++- service/internal/api/handlers_test.go | 15 +- service/internal/api/middleware.go | 174 ++++++ service/internal/api/network_handlers.go | 100 +++ service/internal/api/network_handlers_test.go | 153 +++++ service/internal/api/router.go | 40 +- .../internal/api/security_handlers_test.go | 29 +- service/internal/api/setup_auth_handlers.go | 464 ++++++++++++++ .../internal/api/setup_auth_handlers_test.go | 95 +++ service/internal/api/test_helpers.go | 71 +++ service/internal/api/users_handlers.go | 145 +++++ service/internal/api/users_handlers_test.go | 194 ++++++ service/internal/auth/id.go | 16 + service/internal/auth/network.go | 135 +++++ service/internal/auth/password.go | 128 ++++ service/internal/auth/password_test.go | 70 +++ service/internal/auth/session.go | 250 ++++++++ service/internal/auth/totp.go | 47 ++ service/internal/auth/weak.go | 28 + service/internal/settings/encrypted.go | 78 +++ service/internal/settings/listen.go | 28 + service/internal/settings/path.go | 6 +- service/internal/settings/secrets.go | 132 ++-- service/internal/settings/setup.go | 35 ++ service/internal/settings/types.go | 73 ++- service/internal/websrv/handler.go | 118 ++++ service/internal/websrv/handler_test.go | 128 ++++ webui/package-lock.json | 285 ++++++++- webui/package.json | 2 + webui/src/App.css | 131 ++++ webui/src/App.test.tsx | 349 ++++++++++- webui/src/App.tsx | 474 ++++++++++++++- webui/src/LoginPage.tsx | 89 +++ webui/src/SetupWizard.tsx | 567 ++++++++++++++++++ webui/src/api/client.test.ts | 197 +++++- webui/src/api/client.ts | 327 +++++++++- webui/src/navigation.test.ts | 10 + webui/src/navigation.ts | 24 + webui/src/passwordPolicy.test.ts | 29 + webui/src/passwordPolicy.ts | 76 +++ 49 files changed, 5440 insertions(+), 209 deletions(-) create mode 100644 remotedev.mk.example create mode 100644 service/cmd/webui/main.go create mode 100644 service/go.sum create mode 100644 service/internal/api/middleware.go create mode 100644 service/internal/api/network_handlers.go create mode 100644 service/internal/api/network_handlers_test.go create mode 100644 service/internal/api/setup_auth_handlers.go create mode 100644 service/internal/api/setup_auth_handlers_test.go create mode 100644 service/internal/api/test_helpers.go create mode 100644 service/internal/api/users_handlers.go create mode 100644 service/internal/api/users_handlers_test.go create mode 100644 service/internal/auth/id.go create mode 100644 service/internal/auth/network.go create mode 100644 service/internal/auth/password.go create mode 100644 service/internal/auth/password_test.go create mode 100644 service/internal/auth/session.go create mode 100644 service/internal/auth/totp.go create mode 100644 service/internal/auth/weak.go create mode 100644 service/internal/settings/encrypted.go create mode 100644 service/internal/settings/listen.go create mode 100644 service/internal/settings/setup.go create mode 100644 service/internal/websrv/handler.go create mode 100644 service/internal/websrv/handler_test.go create mode 100644 webui/src/LoginPage.tsx create mode 100644 webui/src/SetupWizard.tsx create mode 100644 webui/src/passwordPolicy.test.ts create mode 100644 webui/src/passwordPolicy.ts diff --git a/.gitignore b/.gitignore index 044c867..0183b9c 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ webui/coverage/ .env .env.* !.env.example +remotedev.mk # IDE / OS .idea/ diff --git a/Makefile b/Makefile index 7a790a7..2d5f415 100644 --- a/Makefile +++ b/Makefile @@ -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://:$(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: diff --git a/README.md b/README.md index d712525..a65428c 100644 --- a/README.md +++ b/README.md @@ -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://: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/`. diff --git a/remotedev.mk.example b/remotedev.mk.example new file mode 100644 index 0000000..be0c83c --- /dev/null +++ b/remotedev.mk.example @@ -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) diff --git a/service/cmd/server/main.go b/service/cmd/server/main.go index 66db61e..f4445c8 100644 --- a/service/cmd/server/main.go +++ b/service/cmd/server/main.go @@ -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) } diff --git a/service/cmd/webui/main.go b/service/cmd/webui/main.go new file mode 100644 index 0000000..a6631c6 --- /dev/null +++ b/service/cmd/webui/main.go @@ -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") +} diff --git a/service/go.mod b/service/go.mod index ad40ea7..4858160 100644 --- a/service/go.mod +++ b/service/go.mod @@ -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 +) diff --git a/service/go.sum b/service/go.sum new file mode 100644 index 0000000..8a347c1 --- /dev/null +++ b/service/go.sum @@ -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= diff --git a/service/internal/api/groups_handlers.go b/service/internal/api/groups_handlers.go index 566b4c9..03a1193 100644 --- a/service/internal/api/groups_handlers.go +++ b/service/internal/api/groups_handlers.go @@ -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") diff --git a/service/internal/api/groups_handlers_test.go b/service/internal/api/groups_handlers_test.go index 23511c1..d9c67c2 100644 --- a/service/internal/api/groups_handlers_test.go +++ b/service/internal/api/groups_handlers_test.go @@ -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) + } +} diff --git a/service/internal/api/handlers_test.go b/service/internal/api/handlers_test.go index 0b2d824..0656ed8 100644 --- a/service/internal/api/handlers_test.go +++ b/service/internal/api/handlers_test.go @@ -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) } diff --git a/service/internal/api/middleware.go b/service/internal/api/middleware.go new file mode 100644 index 0000000..d1685a8 --- /dev/null +++ b/service/internal/api/middleware.go @@ -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 + } +} diff --git a/service/internal/api/network_handlers.go b/service/internal/api/network_handlers.go new file mode 100644 index 0000000..452a1a2 --- /dev/null +++ b/service/internal/api/network_handlers.go @@ -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") + } +} diff --git a/service/internal/api/network_handlers_test.go b/service/internal/api/network_handlers_test.go new file mode 100644 index 0000000..2f466b9 --- /dev/null +++ b/service/internal/api/network_handlers_test.go @@ -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()) + } +} diff --git a/service/internal/api/router.go b/service/internal/api/router.go index 559b7ed..0e5b869 100644 --- a/service/internal/api/router.go +++ b/service/internal/api/router.go @@ -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)) } diff --git a/service/internal/api/security_handlers_test.go b/service/internal/api/security_handlers_test.go index e352810..8ddd93c 100644 --- a/service/internal/api/security_handlers_test.go +++ b/service/internal/api/security_handlers_test.go @@ -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) diff --git a/service/internal/api/setup_auth_handlers.go b/service/internal/api/setup_auth_handlers.go new file mode 100644 index 0000000..f6ae76f --- /dev/null +++ b/service/internal/api/setup_auth_handlers.go @@ -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 +} diff --git a/service/internal/api/setup_auth_handlers_test.go b/service/internal/api/setup_auth_handlers_test.go new file mode 100644 index 0000000..1c71fb3 --- /dev/null +++ b/service/internal/api/setup_auth_handlers_test.go @@ -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()) + } +} diff --git a/service/internal/api/test_helpers.go b/service/internal/api/test_helpers.go new file mode 100644 index 0000000..248e726 --- /dev/null +++ b/service/internal/api/test_helpers.go @@ -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 +} diff --git a/service/internal/api/users_handlers.go b/service/internal/api/users_handlers.go new file mode 100644 index 0000000..a5f5dda --- /dev/null +++ b/service/internal/api/users_handlers.go @@ -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 +} diff --git a/service/internal/api/users_handlers_test.go b/service/internal/api/users_handlers_test.go new file mode 100644 index 0000000..533dd75 --- /dev/null +++ b/service/internal/api/users_handlers_test.go @@ -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) + } +} diff --git a/service/internal/auth/id.go b/service/internal/auth/id.go new file mode 100644 index 0000000..bcc2f67 --- /dev/null +++ b/service/internal/auth/id.go @@ -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 +} diff --git a/service/internal/auth/network.go b/service/internal/auth/network.go new file mode 100644 index 0000000..6a542d1 --- /dev/null +++ b/service/internal/auth/network.go @@ -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 +} diff --git a/service/internal/auth/password.go b/service/internal/auth/password.go new file mode 100644 index 0000000..0914dda --- /dev/null +++ b/service/internal/auth/password.go @@ -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 +} diff --git a/service/internal/auth/password_test.go b/service/internal/auth/password_test.go new file mode 100644 index 0000000..17f5c22 --- /dev/null +++ b/service/internal/auth/password_test.go @@ -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") + } +} diff --git a/service/internal/auth/session.go b/service/internal/auth/session.go new file mode 100644 index 0000000..608a8c5 --- /dev/null +++ b/service/internal/auth/session.go @@ -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 +} diff --git a/service/internal/auth/totp.go b/service/internal/auth/totp.go new file mode 100644 index 0000000..aac7bfc --- /dev/null +++ b/service/internal/auth/totp.go @@ -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 +} diff --git a/service/internal/auth/weak.go b/service/internal/auth/weak.go new file mode 100644 index 0000000..8ac7d87 --- /dev/null +++ b/service/internal/auth/weak.go @@ -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 +} diff --git a/service/internal/settings/encrypted.go b/service/internal/settings/encrypted.go new file mode 100644 index 0000000..11470df --- /dev/null +++ b/service/internal/settings/encrypted.go @@ -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 +} diff --git a/service/internal/settings/listen.go b/service/internal/settings/listen.go new file mode 100644 index 0000000..24813d6 --- /dev/null +++ b/service/internal/settings/listen.go @@ -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) +} diff --git a/service/internal/settings/path.go b/service/internal/settings/path.go index 6a6f625..9172dd6 100644 --- a/service/internal/settings/path.go +++ b/service/internal/settings/path.go @@ -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: diff --git a/service/internal/settings/secrets.go b/service/internal/settings/secrets.go index b630a84..770d0cc 100644 --- a/service/internal/settings/secrets.go +++ b/service/internal/settings/secrets.go @@ -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) } diff --git a/service/internal/settings/setup.go b/service/internal/settings/setup.go new file mode 100644 index 0000000..666be85 --- /dev/null +++ b/service/internal/settings/setup.go @@ -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 +} diff --git a/service/internal/settings/types.go b/service/internal/settings/types.go index ac644c4..7a2f461 100644 --- a/service/internal/settings/types.go +++ b/service/internal/settings/types.go @@ -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"` +} diff --git a/service/internal/websrv/handler.go b/service/internal/websrv/handler.go new file mode 100644 index 0000000..812fd87 --- /dev/null +++ b/service/internal/websrv/handler.go @@ -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)) +} diff --git a/service/internal/websrv/handler_test.go b/service/internal/websrv/handler_test.go new file mode 100644 index 0000000..9108055 --- /dev/null +++ b/service/internal/websrv/handler_test.go @@ -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"), "home") + 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"), "home") + + 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) + } +} diff --git a/webui/package-lock.json b/webui/package-lock.json index fdf3f98..2f05067 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -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" + } } } } diff --git a/webui/package.json b/webui/package.json index cd160d3..23dceb4 100644 --- a/webui/package.json +++ b/webui/package.json @@ -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", diff --git a/webui/src/App.css b/webui/src/App.css index ab1a8d6..a7c1f76 100644 --- a/webui/src/App.css +++ b/webui/src/App.css @@ -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; +} diff --git a/webui/src/App.test.tsx b/webui/src/App.test.tsx index cbb071b..734fe27 100644 --- a/webui/src/App.test.tsx +++ b/webui/src/App.test.tsx @@ -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() 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() - 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() 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() - 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() + 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() 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() + 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() + + 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() + + 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() + 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() + expect( + await screen.findByRole('heading', { name: 'Cluster Canvas' }), + ).toBeInTheDocument() expect(document.documentElement.dataset.theme).toBe('latte') }) @@ -340,6 +654,8 @@ describe('App', () => { render() + 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() + 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() + 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' })) diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 6c75f18..340ab80 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -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 = { 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 ( void handleDeleteGroup(group.name)} > Delete @@ -380,6 +416,312 @@ function GroupsConfigTab() { ) } +function UsersConfigTab() { + const [users, setUsers] = useState>([]) + const [isLoadingUsers, setIsLoadingUsers] = useState(true) + const [usersLoadError, setUsersLoadError] = useState(null) + const [actionError, setActionError] = useState(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 ( +
+
+

Configured accounts

+ {actionError ? ( +

+ {actionError} +

+ ) : null} + {isLoadingUsers ? ( +

Loading…

+ ) : usersLoadError ? ( +

+ {usersLoadError} +

+ ) : users.length === 0 ? ( +

No users configured yet.

+ ) : ( +
    + {users.map((user) => ( +
  • +
    +
    + {user.username} + {user.is_admin ? ( + Admin + ) : null} +
    +
    + Groups:{' '} + {user.group_names.length > 0 + ? user.group_names.join(', ') + : '—'} +
    +
    + Enabled: {user.enabled ? 'yes' : 'no'} · TOTP:{' '} + {user.totp_confirmed ? 'confirmed' : 'not confirmed'} +
    +
    + Created: {formatUserTimestamp(user.created_at)} · Password + changed: {formatUserTimestamp(user.password_changed_at)} +
    +
    ID: {user.id}
    +
    + +
  • + ))} +
+ )} +
+
+ ) +} + +function NetworkConfigTab() { + const [draft, setDraft] = useState(DEFAULT_NETWORK_SETTINGS) + const [rulesText, setRulesText] = useState('') + const [isLoading, setIsLoading] = useState(true) + const [loadError, setLoadError] = useState(null) + const [submitError, setSubmitError] = useState(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

Loading…

+ } + + if (loadError) { + return ( +

+ {loadError} +

+ ) + } + + return ( +
+

+ Listen bind address and IP access control for the admin UI. +

+ + {submitError ? ( +

+ {submitError} +

+ ) : null} + {saveSucceeded ? ( +

+ Network settings saved. +

+ ) : null} + +
+ + { + setDraft((previous) => ({ + ...previous, + listen_address: event.target.value, + })) + setSaveSucceeded(false) + }} + /> +

+ Default 0.0.0.0 (all interfaces). Applied on next process restart. +

+
+ +
+ + { + setDraft((previous) => ({ + ...previous, + public_hostname: event.target.value, + })) + setSaveSucceeded(false) + }} + /> +

+ Leave empty for local access by IP. When set, the UI must be visited + via that exact hostname. +

+
+ +
+ + +
+ + {draft.access_mode !== 'open' ? ( +
+ +