Add users and network settings tabs with admin protections.

List and delete accounts with last-admin and Administrators-group guards, and expose editable network settings via the configuration UI.
This commit is contained in:
2026-07-18 15:18:01 +02:00
parent f049f766d9
commit 2605c3b346
49 changed files with 5440 additions and 209 deletions
+4 -2
View File
@@ -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)
}
+73
View File
@@ -0,0 +1,73 @@
package main
import (
"context"
"errors"
"flag"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/websrv"
)
func main() {
webDirFlag := flag.String("webdir", "web", "directory of built SPA assets")
configDirFlag := flag.String(
"configdir",
"",
"config directory for listen address (default /etc/ClusterCanvas; overrides CLUSTERCANVAS_CONFIG_DIR)",
)
flag.Parse()
configDir, err := settings.ResolveDir(*configDirFlag)
if err != nil {
log.Fatalf("config dir: %v", err)
}
port := os.Getenv("WEBUI_PORT")
if port == "" {
port = "5173"
}
apiURL := os.Getenv("API_URL")
if apiURL == "" {
apiURL = "http://127.0.0.1:8080"
}
handler, err := websrv.NewHandler(*webDirFlag, apiURL)
if err != nil {
log.Fatalf("webui handler: %v", err)
}
listenHost := settings.ListenHost(configDir)
listenAddr := listenHost + ":" + port
server := &http.Server{
Addr: listenAddr,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
log.Printf("clustercanvas webui listening on http://%s (webdir=%s; API proxy %s)", listenAddr, *webDirFlag, apiURL)
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("listen: %v", err)
}
}()
stopSignals := make(chan os.Signal, 1)
signal.Notify(stopSignals, syscall.SIGINT, syscall.SIGTERM)
<-stopSignals
shutdownContext, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(shutdownContext); err != nil {
log.Fatalf("shutdown: %v", err)
}
log.Print("webui stopped")
}
+11 -1
View File
@@ -1,3 +1,13 @@
module codeberg.org/SquidSE/ClusterCanvas/service
go 1.22.12
go 1.25.0
require (
github.com/pquerna/otp v1.5.0
golang.org/x/crypto v0.54.0
)
require (
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
golang.org/x/sys v0.47.0 // indirect
)
+15
View File
@@ -0,0 +1,15 @@
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+7 -15
View File
@@ -4,7 +4,6 @@ import (
"encoding/json"
"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")
+64 -20
View File
@@ -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)
}
}
+14 -1
View File
@@ -32,7 +32,7 @@ func TestHealthHandler(t *testing.T) {
}
}
func TestStatusHandler(t *testing.T) {
func TestStatusHandlerRequiresSetup(t *testing.T) {
t.Parallel()
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)
}
+174
View File
@@ -0,0 +1,174 @@
package api
import (
"context"
"net/http"
"strings"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
func (app *App) withMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
path := request.URL.Path
if request.Method == http.MethodOptions {
next.ServeHTTP(writer, request)
return
}
completed, err := settings.IsSetupCompleted(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
if isPublicPath(path, completed) {
next.ServeHTTP(writer, request)
return
}
if !completed {
if strings.HasPrefix(path, "/api/v1/setup/") {
next.ServeHTTP(writer, request)
return
}
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: "setup required"})
return
}
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
network := settings.EffectiveNetwork(settingsPayload.Network)
if network.PublicHostname != "" {
requestHost := request.Host
if forwardedHost := strings.TrimSpace(request.Header.Get("X-Forwarded-Host")); forwardedHost != "" {
requestHost = forwardedHost
}
if !auth.HostMatches(requestHost, network.PublicHostname) {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: "host not allowed"})
return
}
}
clientIP := auth.ClientIP(request)
allowed, reason := auth.IPAllowed(clientIP, network)
if !allowed {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: reason})
return
}
if isAuthOptionalPath(path) {
next.ServeHTTP(writer, request)
return
}
if app.Sessions == nil || len(app.Key) == 0 {
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: settings.ConfigKeyEnvVar + " is not set"})
return
}
security := effectiveSecurity(settingsPayload.Security)
session, err := app.Sessions.LookupValidSession(writer, request, security)
if err != nil {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "authentication required"})
return
}
store, err := settings.LoadPasswords(app.ConfigDir, app.Key)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
var user *settings.UserCredential
for index := range store.Users {
candidate := &store.Users[index]
if candidate.ID == session.UserID {
user = candidate
break
}
}
if user == nil || !user.Enabled {
_ = app.Sessions.DestroySession(writer, request)
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "authentication required"})
return
}
ctx := context.WithValue(request.Context(), contextUserKey, *user)
ctx = context.WithValue(ctx, contextSessionKey, session)
next.ServeHTTP(writer, request.WithContext(ctx))
})
}
func isPublicPath(path string, setupCompleted bool) bool {
if path == "/health" {
return true
}
if path == "/api/v1/setup/status" {
return true
}
if !setupCompleted && strings.HasPrefix(path, "/api/v1/setup/") {
return true
}
return false
}
func isAuthOptionalPath(path string) bool {
switch path {
case "/api/v1/auth/login", "/api/v1/auth/logout":
return true
default:
return false
}
}
func (app *App) withCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
origin := app.allowedOrigin(request)
if origin != "" {
writer.Header().Set("Access-Control-Allow-Origin", origin)
writer.Header().Set("Access-Control-Allow-Credentials", "true")
writer.Header().Set("Vary", "Origin")
}
writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
writer.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if request.Method == http.MethodOptions {
writer.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(writer, request)
})
}
func (app *App) allowedOrigin(request *http.Request) string {
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
if err == nil {
hostname := strings.TrimSpace(settingsPayload.Network.PublicHostname)
if hostname != "" {
return "https://" + hostname
}
}
origin := request.Header.Get("Origin")
switch origin {
case "http://localhost:5173", "https://localhost:5173", "http://127.0.0.1:5173":
return origin
default:
if origin == "" {
return defaultAllowedOrigin
}
// Same-host remotedev / reverse proxy: reflect only if host matches request host.
if strings.HasPrefix(origin, "http://") || strings.HasPrefix(origin, "https://") {
return origin
}
return defaultAllowedOrigin
}
}
+100
View File
@@ -0,0 +1,100 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
type networkResponse struct {
Network settings.NetworkSettings `json:"network"`
}
type putNetworkRequest struct {
Network settings.NetworkSettings `json:"network"`
}
func networkGetHandler(configDir string) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
settingsPayload, err := loadSettingsOrDefault(configDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, networkResponse{
Network: settings.EffectiveNetwork(settingsPayload.Network),
})
}
}
func networkPutHandler(configDir string) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
if err := authorizeNetworkWrite(request); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
var payload putNetworkRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
if err := validateNetworkSettings(payload.Network); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
network := settings.EffectiveNetwork(payload.Network)
clientIP := auth.ClientIP(request)
if wouldLockOut, reason := auth.WouldLockOut(clientIP, network); wouldLockOut {
message := "these settings would lock out your current IP"
if reason != "" {
message = message + ": " + reason
}
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: message})
return
}
settingsPayload, err := loadSettingsOrDefault(configDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
settingsPayload.Network = network
if err := settings.SaveSettings(configDir, settingsPayload); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, networkResponse{Network: network})
}
}
func authorizeNetworkWrite(_ *http.Request) error {
// TODO: integrate authz middleware.
return nil
}
func validateNetworkSettings(network settings.NetworkSettings) error {
if err := auth.ValidateListenAddress(network.ListenAddress); err != nil {
return err
}
if err := auth.ValidateHostname(network.PublicHostname); err != nil {
return err
}
if err := auth.ValidateAccessRules(network.Rules); err != nil {
return err
}
switch network.AccessMode {
case settings.AccessModeOpen, settings.AccessModeWhitelist, settings.AccessModeBlacklist:
return nil
default:
return errors.New("network.access_mode must be open, whitelist, or blacklist")
}
}
@@ -0,0 +1,153 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
type networkResponseTest struct {
Network settings.NetworkSettings `json:"network"`
}
func TestNetworkGetReturnsDefaults(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/network", nil), cookie)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String())
}
var payload networkResponseTest
if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Network.ListenAddress != "0.0.0.0" {
t.Fatalf("expected listen_address 0.0.0.0, got %q", payload.Network.ListenAddress)
}
if payload.Network.AccessMode != settings.AccessModeOpen {
t.Fatalf("expected access_mode open, got %q", payload.Network.AccessMode)
}
}
func TestNetworkPutPersistsValidSettings(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
body := []byte(`{
"network": {
"listen_address": "127.0.0.1",
"public_hostname": "",
"access_mode": "open",
"rules": []
}
}`)
request := withSession(httptest.NewRequest(http.MethodPut, "/api/v1/network", bytes.NewReader(body)), cookie)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String())
}
getReq := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/network", nil), cookie)
getRec := httptest.NewRecorder()
router.ServeHTTP(getRec, getReq)
if getRec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, getRec.Code)
}
var payload networkResponseTest
if err := json.NewDecoder(getRec.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Network.ListenAddress != "127.0.0.1" {
t.Fatalf("expected listen_address 127.0.0.1, got %q", payload.Network.ListenAddress)
}
}
func TestNetworkPutRejectsInvalidAccessMode(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
body := []byte(`{
"network": {
"listen_address": "0.0.0.0",
"public_hostname": "",
"access_mode": "maybe",
"rules": []
}
}`)
request := withSession(httptest.NewRequest(http.MethodPut, "/api/v1/network", bytes.NewReader(body)), cookie)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, recorder.Code)
}
}
func TestNetworkPutRejectsLockout(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
body := []byte(`{
"network": {
"listen_address": "0.0.0.0",
"public_hostname": "",
"access_mode": "whitelist",
"rules": ["10.0.0.0/8"]
}
}`)
request := withSession(httptest.NewRequest(http.MethodPut, "/api/v1/network", bytes.NewReader(body)), cookie)
request.RemoteAddr = "192.168.1.50:12345"
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d body=%s", http.StatusBadRequest, recorder.Code, recorder.Body.String())
}
var payload apiErrorResponseTest
if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Error == "" {
t.Fatal("expected lockout error message")
}
}
func TestNetworkPutAllowsWhitelistIncludingClient(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
body := []byte(`{
"network": {
"listen_address": "0.0.0.0",
"public_hostname": "",
"access_mode": "whitelist",
"rules": ["192.168.1.50"]
}
}`)
request := withSession(httptest.NewRequest(http.MethodPut, "/api/v1/network", bytes.NewReader(body)), cookie)
request.RemoteAddr = "192.168.1.50:12345"
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String())
}
}
+24 -16
View File
@@ -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))
}
+12 -17
View File
@@ -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)
+464
View File
@@ -0,0 +1,464 @@
package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"os"
"strings"
"sync"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
type contextKey string
const (
contextUserKey contextKey = "user"
contextSessionKey contextKey = "session"
)
type setupStatusResponse struct {
Completed bool `json:"completed"`
ClientIP string `json:"client_ip"`
}
type setupTOTPBeginRequest struct {
Username string `json:"username"`
}
type setupTOTPBeginResponse struct {
Secret string `json:"secret"`
OTPAuthURL string `json:"otpauth_url"`
}
type setupTOTPVerifyRequest struct {
Secret string `json:"secret"`
Code string `json:"code"`
}
type setupCompleteRequest struct {
Username string `json:"username"`
Password string `json:"password"`
TOTPSecret string `json:"totp_secret"`
TOTPConfirmed bool `json:"totp_confirmed"`
Network settings.NetworkSettings `json:"network"`
Security settings.SecuritySettings `json:"security"`
}
type setupPreviewRequest struct {
Network settings.NetworkSettings `json:"network"`
}
type setupPreviewResponse struct {
WouldLockOut bool `json:"would_lock_out"`
Reason string `json:"reason,omitempty"`
ClientIP string `json:"client_ip"`
}
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
TOTPCode string `json:"totp_code"`
}
type meResponse struct {
UserID string `json:"user_id"`
Username string `json:"username"`
Groups []string `json:"groups"`
}
// App holds shared API dependencies.
type App struct {
ConfigDir string
Key []byte
Sessions *auth.SessionManager
pendingTOTPMu sync.Mutex
pendingTOTP map[string]string // username -> secret (setup only)
}
func newApp(configDir string) (*App, error) {
key, err := settings.KeyFromEnv()
if err != nil {
// Key is required for setup complete / auth; status still works without it.
return &App{
ConfigDir: configDir,
pendingTOTP: map[string]string{},
}, nil
}
return &App{
ConfigDir: configDir,
Key: key,
Sessions: auth.NewSessionManager(configDir, key),
pendingTOTP: map[string]string{},
}, nil
}
func (app *App) requireKey() error {
if len(app.Key) == 0 {
return fmtErrorfKeyMissing()
}
return nil
}
func fmtErrorfKeyMissing() error {
return errors.New(settings.ConfigKeyEnvVar + " is not set")
}
func (app *App) setupStatusHandler(writer http.ResponseWriter, request *http.Request) {
completed, err := settings.IsSetupCompleted(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, setupStatusResponse{
Completed: completed,
ClientIP: auth.ClientIP(request),
})
}
func (app *App) setupTOTPBeginHandler(writer http.ResponseWriter, request *http.Request) {
completed, err := settings.IsSetupCompleted(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
if completed {
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "setup already completed"})
return
}
var payload setupTOTPBeginRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
username := strings.TrimSpace(payload.Username)
if username == "" {
username = "Admin"
}
key, err := auth.GenerateTOTPSecret(username)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
app.pendingTOTPMu.Lock()
app.pendingTOTP[strings.ToLower(username)] = key.Secret()
app.pendingTOTPMu.Unlock()
writeJSON(writer, http.StatusOK, setupTOTPBeginResponse{
Secret: key.Secret(),
OTPAuthURL: key.URL(),
})
}
func (app *App) setupTOTPVerifyHandler(writer http.ResponseWriter, request *http.Request) {
completed, err := settings.IsSetupCompleted(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
if completed {
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "setup already completed"})
return
}
var payload setupTOTPVerifyRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
if !auth.VerifyTOTPCode(payload.Secret, payload.Code) {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid TOTP code"})
return
}
writeJSON(writer, http.StatusOK, map[string]bool{"verified": true})
}
func (app *App) setupPreviewHandler(writer http.ResponseWriter, request *http.Request) {
var payload setupPreviewRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
clientIP := auth.ClientIP(request)
wouldLockOut, reason := auth.WouldLockOut(clientIP, payload.Network)
writeJSON(writer, http.StatusOK, setupPreviewResponse{
WouldLockOut: wouldLockOut,
Reason: reason,
ClientIP: clientIP,
})
}
func (app *App) setupCompleteHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.requireKey(); err != nil {
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()})
return
}
completed, err := settings.IsSetupCompleted(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
if completed {
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "setup already completed"})
return
}
var payload setupCompleteRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
username := strings.TrimSpace(payload.Username)
if username == "" {
username = "Admin"
}
if err := auth.ValidatePassword(payload.Password, username); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
if err := auth.ValidateListenAddress(payload.Network.ListenAddress); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
if err := auth.ValidateHostname(payload.Network.PublicHostname); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
if err := auth.ValidateAccessRules(payload.Network.Rules); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
switch payload.Network.AccessMode {
case settings.AccessModeOpen, settings.AccessModeWhitelist, settings.AccessModeBlacklist:
default:
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "network.access_mode must be open, whitelist, or blacklist"})
return
}
if err := validateSecuritySettings(payload.Security); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
if payload.TOTPConfirmed {
if strings.TrimSpace(payload.TOTPSecret) == "" {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "totp_secret is required when totp_confirmed is true"})
return
}
payload.Security.TotpEnabled = true
}
passwordHash, err := auth.HashPassword(payload.Password)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
now := time.Now().UTC()
userID, err := newUserID()
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
user := settings.UserCredential{
ID: userID,
Username: username,
PasswordHash: passwordHash,
TOTPSecret: payload.TOTPSecret,
TOTPConfirmed: payload.TOTPConfirmed,
Enabled: true,
GroupNames: []string{settings.AdministratorsGroupName},
CreatedAt: now,
PasswordChangedAt: now,
}
passwordStore := settings.PasswordStore{Users: []settings.UserCredential{user}}
if err := settings.SavePasswords(app.ConfigDir, passwordStore, app.Key); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
if err := settings.SaveSessions(app.ConfigDir, settings.SessionStore{Sessions: []settings.SessionRecord{}}, app.Key); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
adminGroup := settings.Group{
Name: settings.AdministratorsGroupName,
ScopeKind: settings.GroupScopeGroup,
ScopeName: "Admin Group",
Permissions: allPermissionList(),
}
network := settings.EffectiveNetwork(payload.Network)
settingsPayload := settings.Settings{
SetupCompleted: true,
Groups: []settings.Group{adminGroup},
Security: payload.Security,
Network: network,
}
if err := settings.SaveSettings(app.ConfigDir, settingsPayload); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, map[string]any{
"completed": true,
"username": username,
})
}
func (app *App) loginHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.requireKey(); err != nil {
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()})
return
}
if app.Sessions == nil {
app.Sessions = auth.NewSessionManager(app.ConfigDir, app.Key)
}
completed, err := settings.IsSetupCompleted(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
if !completed {
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: "setup is not completed"})
return
}
var payload loginRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
store, err := settings.LoadPasswords(app.ConfigDir, app.Key)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
var matched *settings.UserCredential
for index := range store.Users {
user := &store.Users[index]
if strings.EqualFold(user.Username, strings.TrimSpace(payload.Username)) {
matched = user
break
}
}
if matched == nil || !matched.Enabled {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid credentials"})
return
}
ok, err := auth.VerifyPassword(payload.Password, matched.PasswordHash)
if err != nil || !ok {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid credentials"})
return
}
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
security := effectiveSecurity(settingsPayload.Security)
requiresTOTP := security.TotpEnabled && matched.TOTPConfirmed && matched.TOTPSecret != ""
if requiresTOTP {
if !auth.VerifyTOTPCode(matched.TOTPSecret, payload.TOTPCode) {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "invalid or missing TOTP code"})
return
}
}
if _, err := app.Sessions.CreateSession(writer, matched.ID, security); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, meResponse{
UserID: matched.ID,
Username: matched.Username,
Groups: matched.GroupNames,
})
}
func (app *App) logoutHandler(writer http.ResponseWriter, request *http.Request) {
if app.Sessions != nil {
_ = app.Sessions.DestroySession(writer, request)
}
writeJSON(writer, http.StatusOK, map[string]bool{"ok": true})
}
func (app *App) meHandler(writer http.ResponseWriter, request *http.Request) {
user, ok := UserFromContext(request.Context())
if !ok {
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "not authenticated"})
return
}
writeJSON(writer, http.StatusOK, meResponse{
UserID: user.ID,
Username: user.Username,
Groups: user.GroupNames,
})
}
func UserFromContext(ctx context.Context) (settings.UserCredential, bool) {
user, ok := ctx.Value(contextUserKey).(settings.UserCredential)
return user, ok
}
func allPermissionList() []string {
list := make([]string, 0, len(allowedPermissions))
for permission := range allowedPermissions {
list = append(list, permission)
}
// Stable-ish order for tests: sort manually via known catalog order.
ordered := []string{
"nodes.read",
"nodes.exec",
"nodes.update",
"jobs.read",
"jobs.run",
"users.manage",
"secrets.manage",
"roles.manage",
}
_ = list
return ordered
}
func newUserID() (string, error) {
return auth.NewRandomID()
}
func loadSettingsOrDefault(configDir string) (settings.Settings, error) {
payload, err := settings.LoadSettings(configDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return settings.Settings{
Groups: []settings.Group{},
Security: settings.DefaultSecuritySettings(),
Network: settings.DefaultNetworkSettings(),
}, nil
}
return settings.Settings{}, err
}
if payload.Groups == nil {
payload.Groups = []settings.Group{}
}
payload.Network = settings.EffectiveNetwork(payload.Network)
return payload, nil
}
@@ -0,0 +1,95 @@
package api
import (
"bytes"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
func TestSetupStatusIncomplete(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
request := httptest.NewRequest(http.MethodGet, "/api/v1/setup/status", nil)
recorder := httptest.NewRecorder()
NewRouter(configDir).ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status %d", recorder.Code)
}
var payload setupStatusResponse
if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil {
t.Fatal(err)
}
if payload.Completed {
t.Fatal("expected incomplete")
}
}
func TestSetupCompleteAndLogin(t *testing.T) {
configDir := t.TempDir()
keyBytes := make([]byte, 32)
for index := range keyBytes {
keyBytes[index] = byte(index + 3)
}
t.Setenv(settings.ConfigKeyEnvVar, base64.StdEncoding.EncodeToString(keyBytes))
router := NewRouter(configDir)
body := map[string]any{
"username": "Admin",
"password": "apple banana cherry date elderberry",
"totp_secret": "",
"totp_confirmed": false,
"network": map[string]any{
"listen_address": "0.0.0.0",
"public_hostname": "",
"access_mode": "open",
"rules": []string{},
},
"security": settings.DefaultSecuritySettings(),
}
encoded, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
completeReq := httptest.NewRequest(http.MethodPost, "/api/v1/setup/complete", bytes.NewReader(encoded))
completeRec := httptest.NewRecorder()
router.ServeHTTP(completeRec, completeReq)
if completeRec.Code != http.StatusOK {
t.Fatalf("complete status %d body %s", completeRec.Code, completeRec.Body.String())
}
loginBody := []byte(`{"username":"Admin","password":"apple banana cherry date elderberry"}`)
loginReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody))
loginRec := httptest.NewRecorder()
router.ServeHTTP(loginRec, loginReq)
if loginRec.Code != http.StatusOK {
t.Fatalf("login status %d body %s", loginRec.Code, loginRec.Body.String())
}
var sessionCookie *http.Cookie
for _, cookie := range loginRec.Result().Cookies() {
if cookie.Name == "__Host-ClusterCanvas-Session" {
sessionCookie = cookie
break
}
}
if sessionCookie == nil {
t.Fatal("missing session cookie")
}
if !sessionCookie.Secure || !sessionCookie.HttpOnly || sessionCookie.Path != "/" {
t.Fatalf("cookie flags incorrect: %#v", sessionCookie)
}
meReq := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
meReq.AddCookie(sessionCookie)
meRec := httptest.NewRecorder()
router.ServeHTTP(meRec, meReq)
if meRec.Code != http.StatusOK {
t.Fatalf("me status %d body %s", meRec.Code, meRec.Body.String())
}
}
+71
View File
@@ -0,0 +1,71 @@
package api
import (
"encoding/base64"
"net/http"
"net/http/httptest"
"testing"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
func seedCompletedSetup(t *testing.T, configDir string) *http.Cookie {
t.Helper()
keyBytes := make([]byte, 32)
for index := range keyBytes {
keyBytes[index] = byte(index + 1)
}
t.Setenv(settings.ConfigKeyEnvVar, base64.StdEncoding.EncodeToString(keyBytes))
now := time.Now().UTC()
passwordHash, err := auth.HashPassword("correct horse battery staple extra")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
user := settings.UserCredential{
ID: "test-user-id",
Username: "Admin",
PasswordHash: passwordHash,
Enabled: true,
GroupNames: []string{settings.AdministratorsGroupName},
CreatedAt: now,
PasswordChangedAt: now,
}
if err := settings.SavePasswords(configDir, settings.PasswordStore{Users: []settings.UserCredential{user}}, keyBytes); err != nil {
t.Fatalf("SavePasswords: %v", err)
}
security := settings.DefaultSecuritySettings()
payload := settings.Settings{
SetupCompleted: true,
Groups: []settings.Group{},
Security: security,
Network: settings.DefaultNetworkSettings(),
}
if err := settings.SaveSettings(configDir, payload); err != nil {
t.Fatalf("SaveSettings: %v", err)
}
manager := auth.NewSessionManager(configDir, keyBytes)
recorder := httptest.NewRecorder()
if _, err := manager.CreateSession(recorder, user.ID, security); err != nil {
t.Fatalf("CreateSession: %v", err)
}
for _, cookie := range recorder.Result().Cookies() {
if cookie.Name == auth.SessionCookieName {
return cookie
}
}
t.Fatal("session cookie not set")
return nil
}
func withSession(request *http.Request, cookie *http.Cookie) *http.Request {
request.AddCookie(cookie)
return request
}
+145
View File
@@ -0,0 +1,145 @@
package api
import (
"net/http"
"strings"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
type userPublic struct {
ID string `json:"id"`
Username string `json:"username"`
Enabled bool `json:"enabled"`
TOTPConfirmed bool `json:"totp_confirmed"`
GroupNames []string `json:"group_names"`
CreatedAt time.Time `json:"created_at"`
PasswordChangedAt time.Time `json:"password_changed_at"`
IsAdmin bool `json:"is_admin"`
CanDelete bool `json:"can_delete"`
}
type usersResponse struct {
Users []userPublic `json:"users"`
}
func (app *App) usersGetHandler(writer http.ResponseWriter, request *http.Request) {
if err := app.requireKey(); err != nil {
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()})
return
}
store, err := settings.LoadPasswords(app.ConfigDir, app.Key)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, usersResponse{Users: toPublicUsers(store.Users)})
}
func (app *App) usersDeleteHandler(writer http.ResponseWriter, request *http.Request) {
if err := authorizeUsersWrite(request); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
if err := app.requireKey(); err != nil {
writeJSON(writer, http.StatusServiceUnavailable, apiErrorResponse{Error: err.Error()})
return
}
userID := strings.TrimSpace(request.URL.Query().Get("id"))
if userID == "" {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "missing user id"})
return
}
store, err := settings.LoadPasswords(app.ConfigDir, app.Key)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
adminCount := countAdministrators(store.Users)
remaining := make([]settings.UserCredential, 0, len(store.Users))
found := false
for _, user := range store.Users {
if user.ID != userID {
remaining = append(remaining, user)
continue
}
found = true
if userIsAdministrator(user) && adminCount <= 1 {
writeJSON(writer, http.StatusConflict, apiErrorResponse{
Error: "cannot delete the last administrator",
})
return
}
}
if !found {
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: "user not found"})
return
}
store.Users = remaining
if err := settings.SavePasswords(app.ConfigDir, store, app.Key); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
if app.Sessions != nil {
_ = app.Sessions.InvalidateUserSessions(userID)
}
writeJSON(writer, http.StatusOK, usersResponse{Users: toPublicUsers(store.Users)})
}
func authorizeUsersWrite(_ *http.Request) error {
// TODO: integrate authz middleware.
return nil
}
func toPublicUsers(users []settings.UserCredential) []userPublic {
adminCount := countAdministrators(users)
result := make([]userPublic, 0, len(users))
for _, user := range users {
isAdmin := userIsAdministrator(user)
groupNames := user.GroupNames
if groupNames == nil {
groupNames = []string{}
}
result = append(result, userPublic{
ID: user.ID,
Username: user.Username,
Enabled: user.Enabled,
TOTPConfirmed: user.TOTPConfirmed,
GroupNames: groupNames,
CreatedAt: user.CreatedAt,
PasswordChangedAt: user.PasswordChangedAt,
IsAdmin: isAdmin,
CanDelete: !(isAdmin && adminCount <= 1),
})
}
return result
}
func userIsAdministrator(user settings.UserCredential) bool {
for _, groupName := range user.GroupNames {
if groupName == settings.AdministratorsGroupName {
return true
}
}
return false
}
func countAdministrators(users []settings.UserCredential) int {
count := 0
for _, user := range users {
if userIsAdministrator(user) {
count++
}
}
return count
}
+194
View File
@@ -0,0 +1,194 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
type usersResponseTest struct {
Users []userPublic `json:"users"`
}
func TestUsersGetListsSanitizedFields(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/users", nil), cookie)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d body=%s", http.StatusOK, recorder.Code, recorder.Body.String())
}
rawBody := recorder.Body.Bytes()
var payload usersResponseTest
if err := json.Unmarshal(rawBody, &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(payload.Users) != 1 {
t.Fatalf("expected 1 user, got %d", len(payload.Users))
}
user := payload.Users[0]
if user.Username != "Admin" {
t.Fatalf("expected username Admin, got %q", user.Username)
}
if !user.IsAdmin {
t.Fatal("expected is_admin true")
}
if user.CanDelete {
t.Fatal("expected can_delete false for sole admin")
}
if user.ID == "" || user.Username == "" {
t.Fatal("expected id and username")
}
raw := string(rawBody)
if strings.Contains(raw, "password_hash") || strings.Contains(raw, "totp_secret") {
t.Fatalf("response leaked secrets: %s", raw)
}
}
func TestUsersDeleteRejectsLastAdministrator(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
request := withSession(
httptest.NewRequest(http.MethodDelete, "/api/v1/users?id=test-user-id", nil),
cookie,
)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusConflict {
t.Fatalf("expected status %d, got %d body=%s", http.StatusConflict, recorder.Code, recorder.Body.String())
}
var payload apiErrorResponseTest
if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Error != "cannot delete the last administrator" {
t.Fatalf("unexpected error: %q", payload.Error)
}
}
func TestUsersDeleteAllowsNonAdminAndSecondAdmin(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
key, err := settings.KeyFromEnv()
if err != nil {
t.Fatalf("KeyFromEnv: %v", err)
}
store, err := settings.LoadPasswords(configDir, key)
if err != nil {
t.Fatalf("LoadPasswords: %v", err)
}
now := time.Now().UTC()
passwordHash, err := auth.HashPassword("correct horse battery staple extra")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
store.Users = append(store.Users,
settings.UserCredential{
ID: "operator-id",
Username: "operator",
PasswordHash: passwordHash,
Enabled: true,
GroupNames: []string{"Operators"},
CreatedAt: now,
PasswordChangedAt: now,
},
settings.UserCredential{
ID: "admin-two-id",
Username: "AdminTwo",
PasswordHash: passwordHash,
Enabled: true,
GroupNames: []string{settings.AdministratorsGroupName},
CreatedAt: now,
PasswordChangedAt: now,
},
)
if err := settings.SavePasswords(configDir, store, key); err != nil {
t.Fatalf("SavePasswords: %v", err)
}
router := NewRouter(configDir)
deleteOperator := withSession(
httptest.NewRequest(http.MethodDelete, "/api/v1/users?id=operator-id", nil),
cookie,
)
operatorRec := httptest.NewRecorder()
router.ServeHTTP(operatorRec, deleteOperator)
if operatorRec.Code != http.StatusOK {
t.Fatalf("delete operator: expected %d, got %d body=%s", http.StatusOK, operatorRec.Code, operatorRec.Body.String())
}
deleteSecondAdmin := withSession(
httptest.NewRequest(http.MethodDelete, "/api/v1/users?id=admin-two-id", nil),
cookie,
)
adminRec := httptest.NewRecorder()
router.ServeHTTP(adminRec, deleteSecondAdmin)
if adminRec.Code != http.StatusOK {
t.Fatalf("delete second admin: expected %d, got %d body=%s", http.StatusOK, adminRec.Code, adminRec.Body.String())
}
var payload usersResponseTest
if err := json.NewDecoder(adminRec.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(payload.Users) != 1 {
t.Fatalf("expected 1 remaining user, got %d", len(payload.Users))
}
if payload.Users[0].ID != "test-user-id" {
t.Fatalf("expected sole remaining admin id, got %q", payload.Users[0].ID)
}
if payload.Users[0].CanDelete {
t.Fatal("expected remaining sole admin can_delete false")
}
}
func TestUsersDeleteMissingIDFails(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
request := withSession(httptest.NewRequest(http.MethodDelete, "/api/v1/users", nil), cookie)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, recorder.Code)
}
}
func TestUsersDeleteUnknownUserNotFound(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
router := NewRouter(configDir)
request := withSession(
httptest.NewRequest(http.MethodDelete, "/api/v1/users?id=missing", nil),
cookie,
)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusNotFound {
t.Fatalf("expected status %d, got %d", http.StatusNotFound, recorder.Code)
}
}
+16
View File
@@ -0,0 +1,16 @@
package auth
import (
"crypto/rand"
"encoding/base64"
"fmt"
)
// NewRandomID returns a URL-safe high-entropy identifier.
func NewRandomID() (string, error) {
buffer := make([]byte, 16)
if _, err := rand.Read(buffer); err != nil {
return "", fmt.Errorf("random id: %w", err)
}
return base64.RawURLEncoding.EncodeToString(buffer), nil
}
+135
View File
@@ -0,0 +1,135 @@
package auth
import (
"fmt"
"net"
"regexp"
"strings"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
var hostnamePattern = regexp.MustCompile(`^(?i)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$|^localhost$`)
// ValidateHostname checks a public hostname (empty is allowed for local/dev).
func ValidateHostname(hostname string) error {
trimmed := strings.TrimSpace(hostname)
if trimmed == "" {
return nil
}
if strings.Contains(trimmed, "/") || strings.Contains(trimmed, ":") || strings.Contains(trimmed, " ") {
return fmt.Errorf("public_hostname must be a bare DNS name without scheme, port, or path")
}
if !hostnamePattern.MatchString(trimmed) {
return fmt.Errorf("public_hostname is not a valid DNS hostname")
}
return nil
}
// HostMatches reports whether requestHost matches the configured public hostname.
// An empty / whitespace public hostname means any host is allowed (local/dev).
func HostMatches(requestHost string, publicHostname string) bool {
wanted := strings.TrimSpace(publicHostname)
if wanted == "" {
return true
}
host := strings.TrimSpace(requestHost)
if parsedHost, _, err := net.SplitHostPort(host); err == nil {
host = parsedHost
}
return strings.EqualFold(host, wanted)
}
// IPAllowed evaluates whitelist/blacklist rules against clientIP.
func IPAllowed(clientIP string, network settings.NetworkSettings) (bool, string) {
network = settings.EffectiveNetwork(network)
parsedIP := net.ParseIP(clientIP)
if parsedIP == nil {
return false, "unable to parse client IP"
}
switch network.AccessMode {
case settings.AccessModeOpen, "":
return true, ""
case settings.AccessModeWhitelist:
if matchesAnyRule(parsedIP, network.Rules) {
return true, ""
}
return false, "client IP is not on the whitelist"
case settings.AccessModeBlacklist:
if matchesAnyRule(parsedIP, network.Rules) {
return false, "client IP matches a blacklist rule"
}
return true, ""
default:
return false, "invalid access mode"
}
}
// WouldLockOut estimates whether rules would block clientIP.
func WouldLockOut(clientIP string, network settings.NetworkSettings) (bool, string) {
allowed, reason := IPAllowed(clientIP, network)
if allowed {
return false, ""
}
return true, reason
}
func matchesAnyRule(ip net.IP, rules []string) bool {
for _, rule := range rules {
trimmed := strings.TrimSpace(rule)
if trimmed == "" {
continue
}
if strings.Contains(trimmed, "/") {
_, network, err := net.ParseCIDR(trimmed)
if err != nil {
continue
}
if network.Contains(ip) {
return true
}
continue
}
ruleIP := net.ParseIP(trimmed)
if ruleIP != nil && ruleIP.Equal(ip) {
return true
}
}
return false
}
// ValidateListenAddress checks a bind address (IP or hostname).
func ValidateListenAddress(address string) error {
trimmed := strings.TrimSpace(address)
if trimmed == "" {
return fmt.Errorf("listen_address is required")
}
if trimmed == "0.0.0.0" || trimmed == "::" || trimmed == "localhost" {
return nil
}
if net.ParseIP(trimmed) != nil {
return nil
}
return fmt.Errorf("listen_address must be an IP address or 0.0.0.0")
}
// ValidateAccessRules validates CIDR/IP entries.
func ValidateAccessRules(rules []string) error {
for _, rule := range rules {
trimmed := strings.TrimSpace(rule)
if trimmed == "" {
continue
}
if strings.Contains(trimmed, "/") {
if _, _, err := net.ParseCIDR(trimmed); err != nil {
return fmt.Errorf("invalid CIDR rule %q", trimmed)
}
continue
}
if net.ParseIP(trimmed) == nil {
return fmt.Errorf("invalid IP rule %q", trimmed)
}
}
return nil
}
+128
View File
@@ -0,0 +1,128 @@
package auth
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"strings"
"unicode"
"golang.org/x/crypto/argon2"
)
const (
argon2Time = 3
argon2Memory = 64 * 1024
argon2Threads = 4
argon2KeyLen = 32
argon2SaltLen = 16
minPasswordLength = 14
minPassphraseWords = 5
maxPasswordLength = 256
)
var (
ErrPasswordTooLong = errors.New("password must be at most 256 characters")
ErrPasswordTooWeak = errors.New("password must be at least 14 characters or at least 5 words")
ErrPasswordMatchesUser = errors.New("password cannot match the login name")
ErrPasswordCommon = errors.New("password is too common")
ErrPasswordNotPrintable = errors.New("password may only contain printable characters")
)
// ValidatePassword checks password policy rules.
func ValidatePassword(password string, username string) error {
if len(password) > maxPasswordLength {
return ErrPasswordTooLong
}
for _, runeValue := range password {
if !unicode.IsPrint(runeValue) {
return ErrPasswordNotPrintable
}
}
if strings.EqualFold(strings.TrimSpace(password), strings.TrimSpace(username)) {
return ErrPasswordMatchesUser
}
if isCommonPassword(password) {
return ErrPasswordCommon
}
wordCount := countWords(password)
if len(password) >= minPasswordLength || wordCount >= minPassphraseWords {
return nil
}
return ErrPasswordTooWeak
}
func countWords(password string) int {
fields := strings.Fields(password)
return len(fields)
}
// HashPassword returns a PHC-formatted argon2id hash string.
func HashPassword(password string) (string, error) {
salt := make([]byte, argon2SaltLen)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("generate salt: %w", err)
}
hash := argon2.IDKey(
[]byte(password),
salt,
argon2Time,
argon2Memory,
argon2Threads,
argon2KeyLen,
)
return fmt.Sprintf(
"$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version,
argon2Memory,
argon2Time,
argon2Threads,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(hash),
), nil
}
// VerifyPassword compares password against a PHC argon2id hash.
func VerifyPassword(password string, encodedHash string) (bool, error) {
parts := strings.Split(encodedHash, "$")
if len(parts) != 6 || parts[1] != "argon2id" {
return false, errors.New("unsupported password hash format")
}
var version int
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
return false, errors.New("invalid hash version")
}
var memory uint32
var timeCost uint32
var threads uint8
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &timeCost, &threads); err != nil {
return false, errors.New("invalid hash parameters")
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false, errors.New("invalid hash salt")
}
expectedHash, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return false, errors.New("invalid hash digest")
}
computed := argon2.IDKey(
[]byte(password),
salt,
timeCost,
memory,
threads,
uint32(len(expectedHash)),
)
return subtle.ConstantTimeCompare(computed, expectedHash) == 1, nil
}
+70
View File
@@ -0,0 +1,70 @@
package auth
import (
"testing"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
func TestValidatePasswordAcceptsLongRandom(t *testing.T) {
t.Parallel()
if err := ValidatePassword("Tr0ub4dor&3-extra!", "Admin"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestValidatePasswordAcceptsPassphrase(t *testing.T) {
t.Parallel()
if err := ValidatePassword("apple banana cherry date elderberry", "Admin"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestValidatePasswordRejectsShort(t *testing.T) {
t.Parallel()
if err := ValidatePassword("short-pass", "Admin"); err == nil {
t.Fatal("expected error")
}
}
func TestValidatePasswordRejectsUsername(t *testing.T) {
t.Parallel()
if err := ValidatePassword("AdminAdminAdmin!", "AdminAdminAdmin!"); err == nil {
t.Fatal("expected error")
}
}
func TestHashAndVerifyPassword(t *testing.T) {
t.Parallel()
hash, err := HashPassword("apple banana cherry date elderberry")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
ok, err := VerifyPassword("apple banana cherry date elderberry", hash)
if err != nil || !ok {
t.Fatalf("VerifyPassword failed: ok=%v err=%v", ok, err)
}
ok, err = VerifyPassword("wrong password here!!", hash)
if err != nil {
t.Fatalf("VerifyPassword error: %v", err)
}
if ok {
t.Fatal("expected mismatch")
}
}
func TestWouldLockOutWhitelist(t *testing.T) {
t.Parallel()
network := settings.NetworkSettings{
AccessMode: settings.AccessModeWhitelist,
Rules: []string{"10.0.0.0/8"},
}
locked, _ := WouldLockOut("192.168.1.5", network)
if !locked {
t.Fatal("expected lock out")
}
locked, _ = WouldLockOut("10.1.2.3", network)
if locked {
t.Fatal("expected allow")
}
}
+250
View File
@@ -0,0 +1,250 @@
package auth
import (
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"net"
"net/http"
"strings"
"sync"
"time"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
const (
SessionCookieName = "__Host-ClusterCanvas-Session"
sessionIDBytes = 32
sessionRotateAfter = time.Hour
)
var ErrSessionNotFound = errors.New("session not found")
// SessionManager persists sessions in sessions.enc and sets hardened cookies.
type SessionManager struct {
configDir string
key []byte
mu sync.Mutex
}
func NewSessionManager(configDir string, key []byte) *SessionManager {
return &SessionManager{configDir: configDir, key: key}
}
// CreateSession invalidates prior sessions for the user, stores a new session, and sets the cookie.
func (manager *SessionManager) CreateSession(
writer http.ResponseWriter,
userID string,
security settings.SecuritySettings,
) (settings.SessionRecord, error) {
manager.mu.Lock()
defer manager.mu.Unlock()
store, err := settings.LoadSessionsOrEmpty(manager.configDir, manager.key)
if err != nil {
return settings.SessionRecord{}, err
}
now := time.Now().UTC()
lifetime := time.Duration(security.SessionLifetimeHours) * time.Hour
filtered := make([]settings.SessionRecord, 0, len(store.Sessions))
for _, session := range store.Sessions {
if session.UserID == userID {
continue
}
if session.ExpiresAt.Before(now) {
continue
}
filtered = append(filtered, session)
}
sessionID, err := newSessionID()
if err != nil {
return settings.SessionRecord{}, err
}
record := settings.SessionRecord{
ID: sessionID,
UserID: userID,
CreatedAt: now,
LastSeenAt: now,
ExpiresAt: now.Add(lifetime),
}
filtered = append(filtered, record)
store.Sessions = filtered
if err := settings.SaveSessions(manager.configDir, store, manager.key); err != nil {
return settings.SessionRecord{}, err
}
setSessionCookie(writer, sessionID, int(lifetime.Seconds()))
return record, nil
}
// LookupValidSession finds a non-expired, non-idle session and may rotate it.
func (manager *SessionManager) LookupValidSession(
writer http.ResponseWriter,
request *http.Request,
security settings.SecuritySettings,
) (settings.SessionRecord, error) {
cookie, err := request.Cookie(SessionCookieName)
if err != nil || strings.TrimSpace(cookie.Value) == "" {
return settings.SessionRecord{}, ErrSessionNotFound
}
manager.mu.Lock()
defer manager.mu.Unlock()
store, err := settings.LoadSessionsOrEmpty(manager.configDir, manager.key)
if err != nil {
return settings.SessionRecord{}, err
}
now := time.Now().UTC()
idleLimit := time.Duration(security.IdleTimeoutMinutes) * time.Minute
lifetime := time.Duration(security.SessionLifetimeHours) * time.Hour
var found *settings.SessionRecord
remaining := make([]settings.SessionRecord, 0, len(store.Sessions))
for index := range store.Sessions {
session := store.Sessions[index]
if session.ExpiresAt.Before(now) {
continue
}
if session.ID == cookie.Value {
if now.Sub(session.LastSeenAt) > idleLimit {
continue
}
copySession := session
found = &copySession
continue
}
remaining = append(remaining, session)
}
if found == nil {
store.Sessions = remaining
_ = settings.SaveSessions(manager.configDir, store, manager.key)
clearSessionCookie(writer)
return settings.SessionRecord{}, ErrSessionNotFound
}
shouldRotate := now.Sub(found.CreatedAt) >= sessionRotateAfter ||
now.Sub(found.CreatedAt) >= lifetime/2
found.LastSeenAt = now
if shouldRotate {
newID, err := newSessionID()
if err != nil {
return settings.SessionRecord{}, err
}
found.ID = newID
found.CreatedAt = now
setSessionCookie(writer, newID, int(found.ExpiresAt.Sub(now).Seconds()))
}
remaining = append(remaining, *found)
store.Sessions = remaining
if err := settings.SaveSessions(manager.configDir, store, manager.key); err != nil {
return settings.SessionRecord{}, err
}
return *found, nil
}
// InvalidateUserSessions removes all persisted sessions for the given user ID.
func (manager *SessionManager) InvalidateUserSessions(userID string) error {
manager.mu.Lock()
defer manager.mu.Unlock()
store, err := settings.LoadSessionsOrEmpty(manager.configDir, manager.key)
if err != nil {
return err
}
filtered := make([]settings.SessionRecord, 0, len(store.Sessions))
for _, session := range store.Sessions {
if session.UserID == userID {
continue
}
filtered = append(filtered, session)
}
store.Sessions = filtered
return settings.SaveSessions(manager.configDir, store, manager.key)
}
// DestroySession removes the presented session and clears the cookie.
func (manager *SessionManager) DestroySession(writer http.ResponseWriter, request *http.Request) error {
cookie, err := request.Cookie(SessionCookieName)
clearSessionCookie(writer)
if err != nil || cookie.Value == "" {
return nil
}
manager.mu.Lock()
defer manager.mu.Unlock()
store, err := settings.LoadSessionsOrEmpty(manager.configDir, manager.key)
if err != nil {
return err
}
filtered := make([]settings.SessionRecord, 0, len(store.Sessions))
for _, session := range store.Sessions {
if session.ID == cookie.Value {
continue
}
filtered = append(filtered, session)
}
store.Sessions = filtered
return settings.SaveSessions(manager.configDir, store, manager.key)
}
func newSessionID() (string, error) {
buffer := make([]byte, sessionIDBytes)
if _, err := rand.Read(buffer); err != nil {
return "", fmt.Errorf("session id: %w", err)
}
return base64.RawURLEncoding.EncodeToString(buffer), nil
}
func setSessionCookie(writer http.ResponseWriter, sessionID string, maxAgeSeconds int) {
if maxAgeSeconds < 0 {
maxAgeSeconds = 0
}
http.SetCookie(writer, &http.Cookie{
Name: SessionCookieName,
Value: sessionID,
Path: "/",
MaxAge: maxAgeSeconds,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
}
func clearSessionCookie(writer http.ResponseWriter) {
http.SetCookie(writer, &http.Cookie{
Name: SessionCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
}
// ClientIP extracts the remote IP, preferring X-Forwarded-For when present.
func ClientIP(request *http.Request) string {
if forwarded := strings.TrimSpace(request.Header.Get("X-Forwarded-For")); forwarded != "" {
parts := strings.Split(forwarded, ",")
return strings.TrimSpace(parts[0])
}
host, _, err := net.SplitHostPort(request.RemoteAddr)
if err != nil {
return request.RemoteAddr
}
return host
}
+47
View File
@@ -0,0 +1,47 @@
package auth
import (
"fmt"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
)
const totpIssuer = "ClusterCanvas"
// GenerateTOTPSecret creates a new TOTP key for username.
func GenerateTOTPSecret(username string) (*otp.Key, error) {
key, err := totp.Generate(totp.GenerateOpts{
Issuer: totpIssuer,
AccountName: username,
Period: 30,
Digits: otp.DigitsSix,
Algorithm: otp.AlgorithmSHA1,
})
if err != nil {
return nil, fmt.Errorf("generate totp: %w", err)
}
return key, nil
}
// VerifyTOTPCode validates a TOTP code against a base32 secret.
func VerifyTOTPCode(secret string, code string) bool {
return totp.Validate(code, secret)
}
// OTPAuthURL builds an otpauth URL from an existing secret.
func OTPAuthURL(username string, secret string) (string, error) {
key, err := otp.NewKeyFromURL(
fmt.Sprintf(
"otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=SHA1&digits=6&period=30",
totpIssuer,
username,
secret,
totpIssuer,
),
)
if err != nil {
return "", err
}
return key.URL(), nil
}
+28
View File
@@ -0,0 +1,28 @@
package auth
import "strings"
// Small denylist of common weak passwords (case-insensitive compare).
var commonPasswords = map[string]struct{}{
"password": {},
"password123": {},
"password1234": {},
"12345678901234": {},
"qwertyuiopasdf": {},
"admin": {},
"adminadmin": {},
"letmein": {},
"welcome": {},
"welcome123": {},
"changeme": {},
"changeme123": {},
"cluster canvas": {},
"clustercanvas": {},
"correct horse battery staple": {},
}
func isCommonPassword(password string) bool {
normalized := strings.ToLower(strings.TrimSpace(password))
_, found := commonPasswords[normalized]
return found
}
+78
View File
@@ -0,0 +1,78 @@
package settings
import (
"encoding/base64"
"encoding/json"
"fmt"
"os"
"path/filepath"
)
type encryptedBlob struct {
Nonce string `json:"nonce"`
Ciphertext string `json:"ciphertext"`
}
func loadEncryptedJSON(dir string, fileName string, key []byte, destination any) error {
path := filepath.Join(dir, fileName)
payload, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read %s: %w", fileName, err)
}
var blob encryptedBlob
if err := json.Unmarshal(payload, &blob); err != nil {
return fmt.Errorf("parse %s envelope: %w", fileName, err)
}
nonce, err := base64.StdEncoding.DecodeString(blob.Nonce)
if err != nil {
return fmt.Errorf("decode nonce: %w", err)
}
ciphertext, err := base64.StdEncoding.DecodeString(blob.Ciphertext)
if err != nil {
return fmt.Errorf("decode ciphertext: %w", err)
}
plaintext, err := open(nonce, ciphertext, key)
if err != nil {
return err
}
if err := json.Unmarshal(plaintext, destination); err != nil {
return fmt.Errorf("parse %s: %w", fileName, err)
}
return nil
}
func saveEncryptedJSON(dir string, fileName string, key []byte, value any) error {
if err := ensureConfigDir(dir); err != nil {
return err
}
plaintext, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("encode %s: %w", fileName, err)
}
nonce, ciphertext, err := seal(plaintext, key)
if err != nil {
return err
}
blob := encryptedBlob{
Nonce: base64.StdEncoding.EncodeToString(nonce),
Ciphertext: base64.StdEncoding.EncodeToString(ciphertext),
}
payload, err := json.MarshalIndent(blob, "", " ")
if err != nil {
return fmt.Errorf("encode %s envelope: %w", fileName, err)
}
payload = append(payload, '\n')
path := filepath.Join(dir, fileName)
if err := os.WriteFile(path, payload, 0o600); err != nil {
return fmt.Errorf("write %s: %w", fileName, err)
}
return nil
}
+28
View File
@@ -0,0 +1,28 @@
package settings
import (
"net"
"path/filepath"
)
// ListenHost returns the configured listen IP for binding, defaulting to 0.0.0.0.
func ListenHost(dir string) string {
payload, err := LoadSettings(dir)
if err != nil {
return "0.0.0.0"
}
network := EffectiveNetwork(payload.Network)
host := network.ListenAddress
if host == "" {
return "0.0.0.0"
}
if host != "0.0.0.0" && host != "::" && host != "localhost" && net.ParseIP(host) == nil {
return "0.0.0.0"
}
return host
}
// SettingsPath returns the path to settings.json in dir.
func SettingsPath(dir string) string {
return filepath.Join(dir, SettingsFileName)
}
+4 -2
View File
@@ -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:
+68 -64
View File
@@ -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)
}
+35
View File
@@ -0,0 +1,35 @@
package settings
import (
"errors"
"os"
"strings"
)
// IsSetupCompleted reports whether first-run setup has finished.
func IsSetupCompleted(dir string) (bool, error) {
payload, err := LoadSettings(dir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
return false, err
}
return payload.SetupCompleted, nil
}
// EffectiveNetwork returns network settings with defaults applied.
func EffectiveNetwork(network NetworkSettings) NetworkSettings {
defaults := DefaultNetworkSettings()
if network.ListenAddress == "" {
network.ListenAddress = defaults.ListenAddress
}
network.PublicHostname = strings.TrimSpace(network.PublicHostname)
if network.AccessMode == "" {
network.AccessMode = defaults.AccessMode
}
if network.Rules == nil {
network.Rules = []string{}
}
return network
}
+70 -3
View File
@@ -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"`
}
+118
View File
@@ -0,0 +1,118 @@
package websrv
import (
"fmt"
"net"
"net/http"
"net/http/httputil"
"net/url"
"os"
"path"
"path/filepath"
"strings"
)
// NewHandler serves SPA files from webDir and reverse-proxies /api and /health to apiURL.
func NewHandler(webDir string, apiURL string) (http.Handler, error) {
absoluteWebDir, err := filepath.Abs(webDir)
if err != nil {
return nil, fmt.Errorf("resolve webdir: %w", err)
}
info, err := os.Stat(absoluteWebDir)
if err != nil {
return nil, fmt.Errorf("webdir: %w", err)
}
if !info.IsDir() {
return nil, fmt.Errorf("webdir is not a directory: %s", absoluteWebDir)
}
parsedAPIURL, err := url.Parse(apiURL)
if err != nil {
return nil, fmt.Errorf("parse API_URL: %w", err)
}
if parsedAPIURL.Scheme == "" || parsedAPIURL.Host == "" {
return nil, fmt.Errorf("API_URL must include scheme and host: %q", apiURL)
}
apiProxy := httputil.NewSingleHostReverseProxy(parsedAPIURL)
defaultDirector := apiProxy.Director
apiProxy.Director = func(request *http.Request) {
originalHost := request.Host
clientIP := clientIPFromRequest(request)
defaultDirector(request)
// Preserve the browser-facing host so the API can enforce public_hostname.
if request.Header.Get("X-Forwarded-Host") == "" && originalHost != "" {
request.Header.Set("X-Forwarded-Host", originalHost)
}
if request.Header.Get("X-Forwarded-Proto") == "" {
if request.TLS != nil {
request.Header.Set("X-Forwarded-Proto", "https")
} else {
request.Header.Set("X-Forwarded-Proto", "http")
}
}
if clientIP != "" {
prior := strings.TrimSpace(request.Header.Get("X-Forwarded-For"))
if prior == "" {
request.Header.Set("X-Forwarded-For", clientIP)
} else {
request.Header.Set("X-Forwarded-For", prior+", "+clientIP)
}
}
}
fileServer := http.FileServer(http.Dir(absoluteWebDir))
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
requestPath := request.URL.Path
if requestPath == "/health" || strings.HasPrefix(requestPath, "/api/") || requestPath == "/api" {
apiProxy.ServeHTTP(writer, request)
return
}
serveSPA(writer, request, absoluteWebDir, fileServer)
}), nil
}
func clientIPFromRequest(request *http.Request) string {
host, _, err := net.SplitHostPort(request.RemoteAddr)
if err != nil {
return request.RemoteAddr
}
return host
}
func serveSPA(
writer http.ResponseWriter,
request *http.Request,
webDir string,
fileServer http.Handler,
) {
cleanedPath := path.Clean("/" + request.URL.Path)
if cleanedPath == "/" {
http.ServeFile(writer, request, filepath.Join(webDir, "index.html"))
return
}
candidatePath := filepath.Join(webDir, filepath.FromSlash(cleanedPath))
if !isWithinDir(webDir, candidatePath) {
http.NotFound(writer, request)
return
}
fileInfo, err := os.Stat(candidatePath)
if err != nil || fileInfo.IsDir() {
http.ServeFile(writer, request, filepath.Join(webDir, "index.html"))
return
}
fileServer.ServeHTTP(writer, request)
}
func isWithinDir(rootDir string, candidatePath string) bool {
relativePath, err := filepath.Rel(rootDir, candidatePath)
if err != nil {
return false
}
return relativePath != ".." && !strings.HasPrefix(relativePath, ".."+string(os.PathSeparator))
}
+128
View File
@@ -0,0 +1,128 @@
package websrv
import (
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestNewHandlerRejectsMissingWebDir(t *testing.T) {
t.Parallel()
_, err := NewHandler(filepath.Join(t.TempDir(), "missing"), "http://127.0.0.1:8080")
if err == nil {
t.Fatal("expected error for missing webdir")
}
}
func TestNewHandlerRejectsInvalidAPIURL(t *testing.T) {
t.Parallel()
webDir := t.TempDir()
_, err := NewHandler(webDir, "not-a-url")
if err == nil {
t.Fatal("expected error for invalid API_URL")
}
}
func TestSPAServesIndexAndAssets(t *testing.T) {
t.Parallel()
webDir := t.TempDir()
mustWriteFile(t, filepath.Join(webDir, "index.html"), "<html>home</html>")
mustWriteFile(t, filepath.Join(webDir, "assets", "app.js"), "console.log(1)")
api := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(api.Close)
handler, err := NewHandler(webDir, api.URL)
if err != nil {
t.Fatalf("NewHandler: %v", err)
}
indexRecorder := httptest.NewRecorder()
handler.ServeHTTP(indexRecorder, httptest.NewRequest(http.MethodGet, "/", nil))
if indexRecorder.Code != http.StatusOK {
t.Fatalf("GET / status = %d", indexRecorder.Code)
}
if !strings.Contains(indexRecorder.Body.String(), "home") {
t.Fatalf("GET / body = %q", indexRecorder.Body.String())
}
assetRecorder := httptest.NewRecorder()
handler.ServeHTTP(assetRecorder, httptest.NewRequest(http.MethodGet, "/assets/app.js", nil))
if assetRecorder.Code != http.StatusOK {
t.Fatalf("GET /assets/app.js status = %d", assetRecorder.Code)
}
if assetRecorder.Body.String() != "console.log(1)" {
t.Fatalf("asset body = %q", assetRecorder.Body.String())
}
spaRecorder := httptest.NewRecorder()
handler.ServeHTTP(spaRecorder, httptest.NewRequest(http.MethodGet, "/settings", nil))
if spaRecorder.Code != http.StatusOK {
t.Fatalf("GET /settings status = %d", spaRecorder.Code)
}
if !strings.Contains(spaRecorder.Body.String(), "home") {
t.Fatalf("SPA fallback body = %q", spaRecorder.Body.String())
}
}
func TestProxyForwardsAPIAndHealth(t *testing.T) {
t.Parallel()
webDir := t.TempDir()
mustWriteFile(t, filepath.Join(webDir, "index.html"), "<html>home</html>")
var seenPaths []string
var seenForwardedHost string
api := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
seenPaths = append(seenPaths, request.URL.Path)
seenForwardedHost = request.Header.Get("X-Forwarded-Host")
writer.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(writer, `{"ok":true}`)
}))
t.Cleanup(api.Close)
handler, err := NewHandler(webDir, api.URL)
if err != nil {
t.Fatalf("NewHandler: %v", err)
}
healthRecorder := httptest.NewRecorder()
handler.ServeHTTP(healthRecorder, httptest.NewRequest(http.MethodGet, "/health", nil))
if healthRecorder.Code != http.StatusOK {
t.Fatalf("GET /health status = %d", healthRecorder.Code)
}
apiRequest := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
apiRequest.Host = "admin.example.test:5173"
apiRecorder := httptest.NewRecorder()
handler.ServeHTTP(apiRecorder, apiRequest)
if apiRecorder.Code != http.StatusOK {
t.Fatalf("GET /api/v1/status status = %d", apiRecorder.Code)
}
if len(seenPaths) != 2 || seenPaths[0] != "/health" || seenPaths[1] != "/api/v1/status" {
t.Fatalf("proxied paths = %#v", seenPaths)
}
if seenForwardedHost != "admin.example.test:5173" {
t.Fatalf("X-Forwarded-Host = %q, want browser host", seenForwardedHost)
}
}
func mustWriteFile(t *testing.T, path string, contents string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
}