Add password authentication to WebUI and implement session management

- Updated WebUI configuration to include a password field for authentication.
- Enhanced the WebUI server to handle login and logout functionality with session management.
- Added tests for authentication and session handling.
- Updated README and example configuration to reflect new authentication features.
This commit is contained in:
Andy Oknen 2025-07-30 08:34:29 +00:00
parent 51e1ef3ed0
commit 113dcbb72a
17 changed files with 676 additions and 74 deletions

View file

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

View file

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

View file

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

147
src/webui/auth_test.go Normal file
View file

@ -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())
}
}

View file

@ -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")

View file

@ -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)
})

View file

@ -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)
})

View file

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

View file

@ -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"))

View file

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

View file

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

View file

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

View file

@ -11,8 +11,15 @@
<body>
<div class="container">
<header>
<h1>🌳 Yggdrasil Web Interface</h1>
<p>Network mesh management dashboard</p>
<div class="header-content">
<div>
<h1>🌳 Yggdrasil Web Interface</h1>
<p>Network mesh management dashboard</p>
</div>
<div class="header-actions">
<button onclick="logout()" class="logout-btn">Logout</button>
</div>
</div>
</header>
<main>
@ -50,6 +57,14 @@
<p>Yggdrasil Network • Minimal WebUI v1.0</p>
</footer>
</div>
<script>
function logout() {
if (confirm('Are you sure you want to logout?')) {
window.location.href = '/auth/logout';
}
}
</script>
</body>
</html>

168
src/webui/static/login.html Normal file
View file

@ -0,0 +1,168 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Yggdrasil Web Interface - Login</title>
<link rel="stylesheet" href="static/style.css">
<style>
.login-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 20px;
}
.login-form {
background: white;
border-radius: 12px;
padding: 40px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 400px;
text-align: center;
}
.login-form h1 {
color: #495057;
margin-bottom: 10px;
font-size: 2rem;
}
.login-form p {
color: #6c757d;
margin-bottom: 30px;
}
.form-group {
margin-bottom: 20px;
text-align: left;
}
.form-group label {
display: block;
margin-bottom: 5px;
color: #495057;
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 12px;
border: 1px solid #ced4da;
border-radius: 6px;
font-size: 16px;
transition: border-color 0.2s ease;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.login-button {
width: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 12px;
border-radius: 6px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.login-button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.login-button:active {
transform: translateY(0);
}
.error-message {
background: #f8d7da;
color: #721c24;
border: 1px solid #f1aeb5;
border-radius: 6px;
padding: 10px;
margin-bottom: 20px;
display: none;
}
.lock-icon {
font-size: 3rem;
margin-bottom: 20px;
color: #667eea;
}
</style>
</head>
<body>
<div class="login-container">
<form class="login-form" onsubmit="return handleLogin(event)">
<div class="lock-icon">🔒</div>
<h1>🌳 Yggdrasil</h1>
<p>Enter password to access the web interface</p>
<div class="error-message" id="errorMessage">
Invalid password. Please try again.
</div>
<div class="form-group">
<label for="password">Password:</label>
<input type="password" id="password" name="password" required autofocus>
</div>
<button type="submit" class="login-button">
Access Dashboard
</button>
</form>
</div>
<script>
async function handleLogin(event) {
event.preventDefault();
const password = document.getElementById('password').value;
const errorMessage = document.getElementById('errorMessage');
try {
const response = await fetch('/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ password: password })
});
if (response.ok) {
// Success - redirect to main page
window.location.href = '/';
} else {
// Show error message
errorMessage.style.display = 'block';
document.getElementById('password').value = '';
document.getElementById('password').focus();
}
} catch (error) {
console.error('Login error:', error);
errorMessage.style.display = 'block';
}
return false;
}
// Hide error message when user starts typing
document.getElementById('password').addEventListener('input', function () {
document.getElementById('errorMessage').style.display = 'none';
});
</script>
</body>
</html>

View file

@ -18,11 +18,22 @@ body {
}
header {
text-align: center;
margin-bottom: 40px;
color: white;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
text-align: left;
}
.header-content > div:first-child {
text-align: center;
flex: 1;
}
header h1 {
font-size: 2.5rem;
margin-bottom: 10px;
@ -34,6 +45,21 @@ header p {
opacity: 0.9;
}
.logout-btn {
background: rgba(255, 255, 255, 0.2);
color: white;
border: 1px solid rgba(255, 255, 255, 0.3);
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: background 0.2s ease;
}
.logout-btn:hover {
background: rgba(255, 255, 255, 0.3);
}
main {
background: white;
border-radius: 12px;
@ -122,6 +148,15 @@ footer {
padding: 10px;
}
.header-content {
flex-direction: column;
gap: 20px;
}
.header-content > div:first-child {
text-align: center;
}
header h1 {
font-size: 2rem;
}

View file

@ -59,7 +59,7 @@ func TestStaticFiles_ProdMode_SetupStaticHandler(t *testing.T) {
mux := http.NewServeMux()
// This should not panic
setupStaticHandler(mux)
testServer := Server("127.0.0.1:0", "", createTestLogger()); setupStaticHandler(mux, testServer)
server := httptest.NewServer(mux)
defer server.Close()

View file

@ -128,7 +128,7 @@ func TestStaticFiles_DevMode_SetupStaticHandler(t *testing.T) {
// Create HTTP server with static handler
mux := http.NewServeMux()
setupStaticHandler(mux)
testServer := Server("127.0.0.1:0", "", createTestLogger()); setupStaticHandler(mux, testServer)
server := httptest.NewServer(mux)
defer server.Close()