mirror of
https://github.com/yggdrasil-network/yggdrasil-go.git
synced 2025-08-24 16:05:07 +03:00
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:
parent
51e1ef3ed0
commit
113dcbb72a
17 changed files with 676 additions and 74 deletions
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
147
src/webui/auth_test.go
Normal 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())
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
|
|
41
src/webui/example_config.hjson
Normal file
41
src/webui/example_config.hjson
Normal 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
|
|
@ -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"))
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
168
src/webui/static/login.html
Normal 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>
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue