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:
@@ -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