Yggdrasil Network • Minimal WebUI v1.0
diff --git a/cmd/yggdrasil/main.go b/cmd/yggdrasil/main.go index 5f183ca3..d66ef779 100644 --- a/cmd/yggdrasil/main.go +++ b/cmd/yggdrasil/main.go @@ -308,7 +308,12 @@ func main() { listenAddr = fmt.Sprintf("%s:%d", cfg.WebUI.Host, cfg.WebUI.Port) } - n.webui = webui.Server(listenAddr, logger) + n.webui = webui.Server(listenAddr, cfg.WebUI.Password, logger) + if cfg.WebUI.Password != "" { + logger.Infof("WebUI password authentication enabled") + } else { + logger.Warnf("WebUI running without password protection") + } go func() { if err := n.webui.Start(); err != nil { logger.Errorf("WebUI server error: %v", err) diff --git a/src/config/config.go b/src/config/config.go index ac2b3ec3..5f4d965b 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -67,9 +67,10 @@ type MulticastInterfaceConfig struct { } type WebUIConfig struct { - Enable bool `comment:"Enable the web interface for managing the node through a browser."` - Port uint16 `comment:"Port for the web interface. Default is 9000."` - Host string `comment:"Host/IP address to bind the web interface to. Empty means all interfaces."` + Enable bool `comment:"Enable the web interface for managing the node through a browser."` + Port uint16 `comment:"Port for the web interface. Default is 9000."` + Host string `comment:"Host/IP address to bind the web interface to. Empty means all interfaces."` + Password string `comment:"Password for accessing the web interface. If empty, no authentication is required."` } // Generates default configuration and returns a pointer to the resulting @@ -94,9 +95,10 @@ func GenerateConfig() *NodeConfig { cfg.NodeInfoPrivacy = false cfg.NodeInfo = map[string]interface{}{} cfg.WebUI = WebUIConfig{ - Enable: false, - Port: 9000, - Host: "127.0.0.1", + Enable: false, + Port: 9000, + Host: "127.0.0.1", + Password: "", } if err := cfg.postprocessConfig(); err != nil { panic(err) diff --git a/src/webui/README.md b/src/webui/README.md index e430837e..b5f122c8 100644 --- a/src/webui/README.md +++ b/src/webui/README.md @@ -7,7 +7,9 @@ This module provides a web interface for managing Yggdrasil node through a brows - ✅ HTTP web server with static files - ✅ Health check endpoint (`/health`) - ✅ Development and production build modes -- ✅ Automatic binding to Yggdrasil IPv6 address +- ✅ Custom session-based authentication +- ✅ Beautiful login page (password-only) +- ✅ Session management with automatic cleanup - ✅ IPv4 and IPv6 support - ✅ Path traversal attack protection @@ -21,7 +23,7 @@ In the Yggdrasil configuration file: "Enable": true, "Port": 9000, "Host": "", - "BindYgg": false + "Password": "your_secure_password" } } ``` @@ -31,27 +33,20 @@ In the Yggdrasil configuration file: - **`Enable`** - enable/disable WebUI - **`Port`** - port for web interface (default 9000) - **`Host`** - IP address to bind to (empty means all interfaces) -- **`BindYgg`** - automatically bind to Yggdrasil IPv6 address +- **`Password`** - password for accessing the web interface (optional, if empty no authentication required) ## Usage -### Standard mode +### Without password authentication ```go -server := webui.Server("127.0.0.1:9000", logger) +server := webui.Server("127.0.0.1:9000", "", logger) ``` -### With core access +### With password authentication ```go -server := webui.ServerWithCore("127.0.0.1:9000", logger, coreInstance) -``` - -### Automatic Yggdrasil address binding - -```go -server := webui.ServerForYggdrasil(9000, logger, coreInstance) -// Automatically binds to [yggdrasil_ipv6]:9000 +server := webui.Server("127.0.0.1:9000", "your_password", logger) ``` ### Starting the server @@ -69,9 +64,12 @@ server.Stop() ## Endpoints -- **`/`** - main page (index.html) -- **`/health`** - health check (returns "OK") -- **`/static/*`** - static files (CSS, JS, images) +- **`/`** - main page (index.html) - requires authentication if password is set +- **`/login.html`** - custom login page (only password required) +- **`/auth/login`** - POST endpoint for authentication +- **`/auth/logout`** - logout endpoint (clears session) +- **`/health`** - health check (returns "OK") - no authentication required +- **`/static/*`** - static files (CSS, JS, images) - requires authentication if password is set ## Build modes @@ -89,6 +87,10 @@ server.Stop() - Configured HTTP timeouts - Header size limits - File MIME type validation +- Custom session-based authentication (password protection) +- HttpOnly and Secure cookies +- Session expiration (24 hours) +- Health check endpoint always accessible without authentication ## Testing diff --git a/src/webui/auth_test.go b/src/webui/auth_test.go new file mode 100644 index 00000000..f32cb5be --- /dev/null +++ b/src/webui/auth_test.go @@ -0,0 +1,147 @@ +package webui + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gologme/log" +) + +func TestSessionAuthentication(t *testing.T) { + logger := log.New(nil, "test: ", log.Flags()) + + // Test server with password + server := Server("127.0.0.1:0", "testpassword", logger) + + // Test cases for login endpoint + loginTests := []struct { + name string + password string + expectCode int + }{ + {"Wrong password", "wrongpass", http.StatusUnauthorized}, + {"Correct password", "testpassword", http.StatusOK}, + } + + for _, tt := range loginTests { + t.Run("Login_"+tt.name, func(t *testing.T) { + loginData := fmt.Sprintf(`{"password":"%s"}`, tt.password) + req := httptest.NewRequest("POST", "/auth/login", strings.NewReader(loginData)) + req.Header.Set("Content-Type", "application/json") + + rr := httptest.NewRecorder() + server.loginHandler(rr, req) + + if rr.Code != tt.expectCode { + t.Errorf("Expected status code %d, got %d", tt.expectCode, rr.Code) + } + }) + } + + // Test protected resource access + t.Run("Protected_resource_without_session", func(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + rr := httptest.NewRecorder() + + handler := server.authMiddleware(func(rw http.ResponseWriter, r *http.Request) { + rw.WriteHeader(http.StatusOK) + }) + + handler(rr, req) + + // Should redirect to login + if rr.Code != http.StatusSeeOther { + t.Errorf("Expected redirect (303), got %d", rr.Code) + } + }) +} + +func TestNoPasswordAuthentication(t *testing.T) { + logger := log.New(nil, "test: ", log.Flags()) + + // Test server without password + server := Server("127.0.0.1:0", "", logger) + + req := httptest.NewRequest("GET", "/", nil) + rr := httptest.NewRecorder() + + // Create handler function for testing + handler := server.authMiddleware(func(rw http.ResponseWriter, r *http.Request) { + rw.WriteHeader(http.StatusOK) + }) + + handler(rr, req) + + // Should allow access without auth when no password is set + if rr.Code != http.StatusOK { + t.Errorf("Expected access without auth when no password is set, got %d", rr.Code) + } +} + +func TestSessionWorkflow(t *testing.T) { + logger := log.New(nil, "test: ", log.Flags()) + server := Server("127.0.0.1:0", "testpassword", logger) + + // 1. Login to get session + loginData := `{"password":"testpassword"}` + loginReq := httptest.NewRequest("POST", "/auth/login", strings.NewReader(loginData)) + loginReq.Header.Set("Content-Type", "application/json") + loginRR := httptest.NewRecorder() + + server.loginHandler(loginRR, loginReq) + + if loginRR.Code != http.StatusOK { + t.Fatalf("Login failed, expected 200, got %d", loginRR.Code) + } + + // Extract session cookie + cookies := loginRR.Result().Cookies() + var sessionCookie *http.Cookie + for _, cookie := range cookies { + if cookie.Name == "ygg_session" { + sessionCookie = cookie + break + } + } + + if sessionCookie == nil { + t.Fatal("No session cookie found after login") + } + + // 2. Access protected resource with session + protectedReq := httptest.NewRequest("GET", "/", nil) + protectedReq.AddCookie(sessionCookie) + protectedRR := httptest.NewRecorder() + + handler := server.authMiddleware(func(rw http.ResponseWriter, r *http.Request) { + rw.WriteHeader(http.StatusOK) + }) + + handler(protectedRR, protectedReq) + + if protectedRR.Code != http.StatusOK { + t.Errorf("Expected access with valid session, got %d", protectedRR.Code) + } +} + +func TestHealthEndpointNoAuth(t *testing.T) { + req := httptest.NewRequest("GET", "/health", nil) + rr := httptest.NewRecorder() + + // Health endpoint should not require auth + http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + rw.WriteHeader(http.StatusOK) + _, _ = rw.Write([]byte("OK")) + }).ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("Health endpoint should be accessible without auth, got status %d", rr.Code) + } + + if rr.Body.String() != "OK" { + t.Errorf("Expected 'OK', got '%s'", rr.Body.String()) + } +} diff --git a/src/webui/config_test.go b/src/webui/config_test.go index 647b5d9c..eceb6f39 100644 --- a/src/webui/config_test.go +++ b/src/webui/config_test.go @@ -118,7 +118,7 @@ func TestWebUIConfig_Validation(t *testing.T) { // Try to create server with this config logger := createTestLogger() - server := Server(listenAddr, logger) + server := Server(listenAddr, "", logger) if server == nil { t.Error("Failed to create server with valid config") } @@ -152,7 +152,7 @@ func TestWebUIConfig_PortRanges(t *testing.T) { for _, test := range portTests { t.Run(test.description, func(t *testing.T) { listenAddr := fmt.Sprintf("127.0.0.1:%d", test.port) - server := Server(listenAddr, logger) + server := Server(listenAddr, "", logger) if server == nil { t.Errorf("Failed to create server for %s", test.description) @@ -207,7 +207,7 @@ func TestWebUIConfig_HostFormats(t *testing.T) { t.Errorf("Expected %s, got %s", test.expected, listenAddr) } - server := Server(listenAddr, logger) + server := Server(listenAddr, "", logger) if server == nil { t.Errorf("Failed to create server with %s", test.description) } @@ -228,7 +228,7 @@ func TestWebUIConfig_Integration(t *testing.T) { listenAddr := fmt.Sprintf("%s:%d", cfg.WebUI.Host, cfg.WebUI.Port) logger := createTestLogger() - server := Server(listenAddr, logger) + server := Server(listenAddr, "", logger) if server == nil { t.Fatal("Failed to create server from generated config") @@ -303,7 +303,7 @@ func TestWebUIConfig_EdgeCases(t *testing.T) { }, test: func(t *testing.T, cfg config.WebUIConfig) { listenAddr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) - server := Server(listenAddr, logger) + server := Server(listenAddr, "", logger) if server == nil { t.Error("Should be able to create server with max port") } @@ -318,7 +318,7 @@ func TestWebUIConfig_EdgeCases(t *testing.T) { }, test: func(t *testing.T, cfg config.WebUIConfig) { listenAddr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) - server := Server(listenAddr, logger) + server := Server(listenAddr, "", logger) // Server creation should not panic, even with invalid host if server == nil { t.Error("Server creation should not fail due to host format") diff --git a/src/webui/endpoints_test.go b/src/webui/endpoints_test.go index 6916ee2d..33545ff7 100644 --- a/src/webui/endpoints_test.go +++ b/src/webui/endpoints_test.go @@ -15,7 +15,7 @@ func TestWebUIServer_RootEndpoint(t *testing.T) { // Use httptest.Server for more reliable testing mux := http.NewServeMux() - setupStaticHandler(mux) + testServer := Server("127.0.0.1:0", "", createTestLogger()); setupStaticHandler(mux, testServer) mux.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) { serveFile(rw, r, logger) }) @@ -41,7 +41,7 @@ func TestWebUIServer_HealthEndpointDetails(t *testing.T) { // Use httptest.Server for more reliable testing mux := http.NewServeMux() - setupStaticHandler(mux) + testServer := Server("127.0.0.1:0", "", createTestLogger()); setupStaticHandler(mux, testServer) mux.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) { serveFile(rw, r, logger) }) @@ -103,7 +103,7 @@ func TestWebUIServer_NonExistentEndpoint(t *testing.T) { // Use httptest.Server for more reliable testing mux := http.NewServeMux() - setupStaticHandler(mux) + testServer := Server("127.0.0.1:0", "", createTestLogger()); setupStaticHandler(mux, testServer) mux.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) { serveFile(rw, r, logger) }) @@ -152,7 +152,7 @@ func TestWebUIServer_ContentTypes(t *testing.T) { mux := http.NewServeMux() // Setup handlers like in the actual server - setupStaticHandler(mux) + testServer := Server("127.0.0.1:0", "", createTestLogger()); setupStaticHandler(mux, testServer) mux.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) { serveFile(rw, r, logger) }) @@ -182,7 +182,7 @@ func TestWebUIServer_HeaderSecurity(t *testing.T) { // Use httptest.Server for more reliable testing mux := http.NewServeMux() - setupStaticHandler(mux) + testServer := Server("127.0.0.1:0", "", createTestLogger()); setupStaticHandler(mux, testServer) mux.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) { serveFile(rw, r, logger) }) @@ -221,7 +221,7 @@ func TestWebUIServer_ConcurrentRequests(t *testing.T) { // Use httptest.Server for more reliable testing mux := http.NewServeMux() - setupStaticHandler(mux) + testServer := Server("127.0.0.1:0", "", createTestLogger()); setupStaticHandler(mux, testServer) mux.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) { serveFile(rw, r, logger) }) diff --git a/src/webui/error_handling_test.go b/src/webui/error_handling_test.go index c8dfd6f0..9663ad4a 100644 --- a/src/webui/error_handling_test.go +++ b/src/webui/error_handling_test.go @@ -28,7 +28,7 @@ func TestWebUIServer_InvalidListenAddress(t *testing.T) { for _, tc := range testCases { t.Run(fmt.Sprintf("Address_%s_%s", tc.addr, tc.description), func(t *testing.T) { - server := Server(tc.addr, logger) + server := Server(tc.addr, "", logger) // Use a timeout to prevent hanging on addresses that might partially work done := make(chan error, 1) @@ -71,7 +71,7 @@ func TestWebUIServer_PortAlreadyInUse(t *testing.T) { usedPort := listener.Addr().(*net.TCPAddr).Port conflictAddress := fmt.Sprintf("127.0.0.1:%d", usedPort) - server := Server(conflictAddress, logger) + server := Server(conflictAddress, "", logger) // This should fail because port is already in use err = server.Start() @@ -85,8 +85,8 @@ func TestWebUIServer_DoubleStart(t *testing.T) { logger := createTestLogger() // Create two separate servers to test behavior - server1 := Server("127.0.0.1:0", logger) - server2 := Server("127.0.0.1:0", logger) + server1 := Server("127.0.0.1:0", "", logger) + server2 := Server("127.0.0.1:0", "", logger) // Start first server startDone1 := make(chan error, 1) @@ -142,7 +142,7 @@ func TestWebUIServer_DoubleStart(t *testing.T) { func TestWebUIServer_StopTwice(t *testing.T) { logger := createTestLogger() - server := Server("127.0.0.1:0", logger) + server := Server("127.0.0.1:0", "", logger) // Start server go func() { @@ -176,7 +176,7 @@ func TestWebUIServer_GracefulShutdown(t *testing.T) { addr := listener.Addr().String() listener.Close() // Close so our server can use it - server := Server(addr, logger) + server := Server(addr, "", logger) // Channel to track when Start() returns startDone := make(chan error, 1) @@ -230,7 +230,7 @@ func TestWebUIServer_GracefulShutdown(t *testing.T) { func TestWebUIServer_ContextCancellation(t *testing.T) { logger := createTestLogger() - server := Server("127.0.0.1:0", logger) + server := Server("127.0.0.1:0", "", logger) // Create context with timeout ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) @@ -255,7 +255,7 @@ func TestWebUIServer_ContextCancellation(t *testing.T) { func TestWebUIServer_LoggerNil(t *testing.T) { // Test server creation with nil logger - server := Server("127.0.0.1:0", nil) + server := Server("127.0.0.1:0", "", nil) if server == nil { t.Fatal("Server should be created even with nil logger") @@ -271,7 +271,7 @@ func TestWebUIServer_RapidStartStop(t *testing.T) { // Test rapid start/stop cycles with fewer iterations for i := 0; i < 5; i++ { - server := Server("127.0.0.1:0", logger) + server := Server("127.0.0.1:0", "", logger) // Start server startDone := make(chan error, 1) @@ -306,7 +306,7 @@ func TestWebUIServer_LargeNumberOfRequests(t *testing.T) { // Use httptest.Server for more reliable testing mux := http.NewServeMux() - setupStaticHandler(mux) + testServer := Server("127.0.0.1:0", "", createTestLogger()); setupStaticHandler(mux, testServer) mux.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) { serveFile(rw, r, logger) }) diff --git a/src/webui/example_config.hjson b/src/webui/example_config.hjson new file mode 100644 index 00000000..b96a858f --- /dev/null +++ b/src/webui/example_config.hjson @@ -0,0 +1,41 @@ +{ + // Example Yggdrasil configuration with WebUI password authentication + + "PrivateKey": "your_private_key_here", + "PublicKey": "your_public_key_here", + + // ... other Yggdrasil configuration options ... + + // Web interface configuration + "WebUI": { + "Enable": true, + "Port": 9000, + "Host": "127.0.0.1", // Bind only to localhost for security + "Password": "your_secure_password_here" // Set a strong password + } +} + +// Usage examples: +// +// 1. Enable WebUI with password protection: +// Set "Password" to a strong password +// Users will see a custom login page asking only for password +// +// 2. Disable password protection: +// Set "Password" to "" (empty string) +// WebUI will be accessible without authentication +// +// 3. Disable WebUI entirely: +// Set "Enable" to false +// +// Authentication features: +// - Custom login page (no username required, only password) +// - Session-based authentication with secure cookies +// - 24-hour session expiration +// - Automatic session cleanup +// +// Security recommendations: +// - Use a strong, unique password (12+ characters) +// - Bind to localhost (127.0.0.1) unless you need remote access +// - Consider using HTTPS reverse proxy for production deployments +// - Sessions are stored in memory and lost on server restart \ No newline at end of file diff --git a/src/webui/server.go b/src/webui/server.go index 8ffb44bc..c0a5f3c6 100644 --- a/src/webui/server.go +++ b/src/webui/server.go @@ -1,27 +1,193 @@ package webui import ( + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "encoding/json" "fmt" "net" "net/http" + "strings" + "sync" "time" "github.com/yggdrasil-network/yggdrasil-go/src/core" ) type WebUIServer struct { - server *http.Server - log core.Logger - listen string + server *http.Server + log core.Logger + listen string + password string + sessions map[string]time.Time // sessionID -> expiry time + sessionsMux sync.RWMutex } -func Server(listen string, log core.Logger) *WebUIServer { +type LoginRequest struct { + Password string `json:"password"` +} + +func Server(listen string, password string, log core.Logger) *WebUIServer { return &WebUIServer{ - listen: listen, - log: log, + listen: listen, + password: password, + log: log, + sessions: make(map[string]time.Time), } } +// generateSessionID creates a random session ID +func (w *WebUIServer) generateSessionID() string { + bytes := make([]byte, 32) + rand.Read(bytes) + return hex.EncodeToString(bytes) +} + +// isValidSession checks if a session is valid and not expired +func (w *WebUIServer) isValidSession(sessionID string) bool { + w.sessionsMux.RLock() + defer w.sessionsMux.RUnlock() + + expiry, exists := w.sessions[sessionID] + if !exists { + return false + } + + if time.Now().After(expiry) { + // Session expired, clean it up + go func() { + w.sessionsMux.Lock() + delete(w.sessions, sessionID) + w.sessionsMux.Unlock() + }() + return false + } + + return true +} + +// createSession creates a new session for the user +func (w *WebUIServer) createSession() string { + sessionID := w.generateSessionID() + expiry := time.Now().Add(24 * time.Hour) // Session valid for 24 hours + + w.sessionsMux.Lock() + w.sessions[sessionID] = expiry + w.sessionsMux.Unlock() + + return sessionID +} + +// cleanupExpiredSessions removes expired sessions +func (w *WebUIServer) cleanupExpiredSessions() { + w.sessionsMux.Lock() + defer w.sessionsMux.Unlock() + + now := time.Now() + for sessionID, expiry := range w.sessions { + if now.After(expiry) { + delete(w.sessions, sessionID) + } + } +} + +// authMiddleware checks for valid session or redirects to login +func (w *WebUIServer) authMiddleware(next http.HandlerFunc) http.HandlerFunc { + return func(rw http.ResponseWriter, r *http.Request) { + // Skip authentication if no password is set + if w.password == "" { + next(rw, r) + return + } + + // Check for session cookie + cookie, err := r.Cookie("ygg_session") + if err != nil || !w.isValidSession(cookie.Value) { + // No valid session - redirect to login page + if r.URL.Path == "/login.html" || strings.HasPrefix(r.URL.Path, "/auth/") { + // Allow access to login page and auth endpoints + next(rw, r) + return + } + + // For API calls, return 401 + if strings.HasPrefix(r.URL.Path, "/api/") { + rw.WriteHeader(http.StatusUnauthorized) + return + } + + // For regular pages, redirect to login + http.Redirect(rw, r, "/login.html", http.StatusSeeOther) + return + } + + next(rw, r) + } +} + +// loginHandler handles password authentication +func (w *WebUIServer) loginHandler(rw http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var loginReq LoginRequest + if err := json.NewDecoder(r.Body).Decode(&loginReq); err != nil { + http.Error(rw, "Invalid request", http.StatusBadRequest) + return + } + + // Check password + if subtle.ConstantTimeCompare([]byte(loginReq.Password), []byte(w.password)) != 1 { + w.log.Debugf("Authentication failed for request from %s", r.RemoteAddr) + http.Error(rw, "Invalid password", http.StatusUnauthorized) + return + } + + // Create session + sessionID := w.createSession() + + // Set session cookie + http.SetCookie(rw, &http.Cookie{ + Name: "ygg_session", + Value: sessionID, + Path: "/", + HttpOnly: true, + Secure: r.TLS != nil, // Only set Secure flag if using HTTPS + SameSite: http.SameSiteStrictMode, + MaxAge: 24 * 60 * 60, // 24 hours + }) + + w.log.Debugf("Successful authentication for request from %s", r.RemoteAddr) + rw.WriteHeader(http.StatusOK) +} + +// logoutHandler handles logout +func (w *WebUIServer) logoutHandler(rw http.ResponseWriter, r *http.Request) { + // Get session cookie + cookie, err := r.Cookie("ygg_session") + if err == nil { + // Remove session from server + w.sessionsMux.Lock() + delete(w.sessions, cookie.Value) + w.sessionsMux.Unlock() + } + + // Clear session cookie + http.SetCookie(rw, &http.Cookie{ + Name: "ygg_session", + Value: "", + Path: "/", + HttpOnly: true, + MaxAge: -1, // Delete cookie + }) + + // Redirect to login page + http.Redirect(rw, r, "/login.html", http.StatusSeeOther) +} + func (w *WebUIServer) Start() error { // Validate listen address before starting if w.listen != "" { @@ -30,17 +196,30 @@ func (w *WebUIServer) Start() error { } } + // Start session cleanup routine + go func() { + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + for range ticker.C { + w.cleanupExpiredSessions() + } + }() + mux := http.NewServeMux() + // Authentication endpoints - no auth required + mux.HandleFunc("/auth/login", w.loginHandler) + mux.HandleFunc("/auth/logout", w.logoutHandler) + // Setup static files handler (implementation varies by build) - setupStaticHandler(mux) + setupStaticHandler(mux, w) - // Serve any file by path (implementation varies by build) - mux.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) { + // Serve any file by path (implementation varies by build) - with auth + mux.HandleFunc("/", w.authMiddleware(func(rw http.ResponseWriter, r *http.Request) { serveFile(rw, r, w.log) - }) + })) - // Health check endpoint + // Health check endpoint - no auth required mux.HandleFunc("/health", func(rw http.ResponseWriter, r *http.Request) { rw.WriteHeader(http.StatusOK) _, _ = rw.Write([]byte("OK")) diff --git a/src/webui/server_dev.go b/src/webui/server_dev.go index 5ea9196b..93c7f044 100644 --- a/src/webui/server_dev.go +++ b/src/webui/server_dev.go @@ -14,9 +14,12 @@ import ( ) // setupStaticHandler configures static file serving for development (files from disk) -func setupStaticHandler(mux *http.ServeMux) { - // Serve static files from disk for development - mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("src/webui/static/")))) +func setupStaticHandler(mux *http.ServeMux, server *WebUIServer) { + // Serve static files from disk for development - with auth + staticHandler := http.StripPrefix("/static/", http.FileServer(http.Dir("src/webui/static/"))) + mux.HandleFunc("/static/", server.authMiddleware(func(rw http.ResponseWriter, r *http.Request) { + staticHandler.ServeHTTP(rw, r) + })) } // serveFile serves any file from disk or returns 404 if not found diff --git a/src/webui/server_prod.go b/src/webui/server_prod.go index 116805dc..7a81d246 100644 --- a/src/webui/server_prod.go +++ b/src/webui/server_prod.go @@ -18,15 +18,19 @@ import ( var staticFiles embed.FS // setupStaticHandler configures static file serving for production (embedded files) -func setupStaticHandler(mux *http.ServeMux) { +func setupStaticHandler(mux *http.ServeMux, server *WebUIServer) { // Get the embedded file system for static files staticFS, err := fs.Sub(staticFiles, "static") if err != nil { panic("failed to get embedded static files: " + err.Error()) } - // Serve static files from embedded FS - mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))) + // Serve static files from embedded FS - with auth + staticHandler := http.FileServer(http.FS(staticFS)) + mux.HandleFunc("/static/", server.authMiddleware(func(rw http.ResponseWriter, r *http.Request) { + // Strip the /static/ prefix before serving + http.StripPrefix("/static/", staticHandler).ServeHTTP(rw, r) + })) } // serveFile serves any file from embedded files or returns 404 if not found diff --git a/src/webui/server_test.go b/src/webui/server_test.go index 42cb9835..7b1c6f7a 100644 --- a/src/webui/server_test.go +++ b/src/webui/server_test.go @@ -26,7 +26,7 @@ func TestWebUIServer_Creation(t *testing.T) { logger := createTestLogger() listen := getTestAddress() - server := Server(listen, logger) + server := Server(listen, "", logger) if server == nil { t.Fatal("Server function returned nil") @@ -49,7 +49,7 @@ func TestWebUIServer_StartStop(t *testing.T) { logger := createTestLogger() listen := getTestAddress() - server := Server(listen, logger) + server := Server(listen, "", logger) // Start server in goroutine errChan := make(chan error, 1) @@ -86,7 +86,7 @@ func TestWebUIServer_StopWithoutStart(t *testing.T) { logger := createTestLogger() listen := getTestAddress() - server := Server(listen, logger) + server := Server(listen, "", logger) // Stop server that was never started should not error err := server.Stop() @@ -100,7 +100,8 @@ func TestWebUIServer_HealthEndpoint(t *testing.T) { // Create a test server using net/http/httptest for reliable testing mux := http.NewServeMux() - setupStaticHandler(mux) + testServer := Server("127.0.0.1:0", "", logger) + setupStaticHandler(mux, testServer) mux.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) { serveFile(rw, r, logger) }) @@ -135,7 +136,7 @@ func TestWebUIServer_HealthEndpoint(t *testing.T) { func TestWebUIServer_Timeouts(t *testing.T) { logger := createTestLogger() - server := Server("127.0.0.1:0", logger) + server := Server("127.0.0.1:0", "", logger) // Start server go func() { @@ -173,7 +174,7 @@ func TestWebUIServer_ConcurrentStartStop(t *testing.T) { // Test concurrent start/stop operations with separate servers for i := 0; i < 3; i++ { - server := Server("127.0.0.1:0", logger) + server := Server("127.0.0.1:0", "", logger) // Start server startDone := make(chan error, 1) diff --git a/src/webui/static/index.html b/src/webui/static/index.html index c68ce3bc..3c8d7a72 100644 --- a/src/webui/static/index.html +++ b/src/webui/static/index.html @@ -11,8 +11,15 @@
Network mesh management dashboard
+Network mesh management dashboard
+Yggdrasil Network • Minimal WebUI v1.0