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