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)
|
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() {
|
go func() {
|
||||||
if err := n.webui.Start(); err != nil {
|
if err := n.webui.Start(); err != nil {
|
||||||
logger.Errorf("WebUI server error: %v", err)
|
logger.Errorf("WebUI server error: %v", err)
|
||||||
|
|
|
@ -67,9 +67,10 @@ type MulticastInterfaceConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebUIConfig struct {
|
type WebUIConfig struct {
|
||||||
Enable bool `comment:"Enable the web interface for managing the node through a browser."`
|
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."`
|
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."`
|
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
|
// Generates default configuration and returns a pointer to the resulting
|
||||||
|
@ -94,9 +95,10 @@ func GenerateConfig() *NodeConfig {
|
||||||
cfg.NodeInfoPrivacy = false
|
cfg.NodeInfoPrivacy = false
|
||||||
cfg.NodeInfo = map[string]interface{}{}
|
cfg.NodeInfo = map[string]interface{}{}
|
||||||
cfg.WebUI = WebUIConfig{
|
cfg.WebUI = WebUIConfig{
|
||||||
Enable: false,
|
Enable: false,
|
||||||
Port: 9000,
|
Port: 9000,
|
||||||
Host: "127.0.0.1",
|
Host: "127.0.0.1",
|
||||||
|
Password: "",
|
||||||
}
|
}
|
||||||
if err := cfg.postprocessConfig(); err != nil {
|
if err := cfg.postprocessConfig(); err != nil {
|
||||||
panic(err)
|
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
|
- ✅ HTTP web server with static files
|
||||||
- ✅ Health check endpoint (`/health`)
|
- ✅ Health check endpoint (`/health`)
|
||||||
- ✅ Development and production build modes
|
- ✅ 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
|
- ✅ IPv4 and IPv6 support
|
||||||
- ✅ Path traversal attack protection
|
- ✅ Path traversal attack protection
|
||||||
|
|
||||||
|
@ -21,7 +23,7 @@ In the Yggdrasil configuration file:
|
||||||
"Enable": true,
|
"Enable": true,
|
||||||
"Port": 9000,
|
"Port": 9000,
|
||||||
"Host": "",
|
"Host": "",
|
||||||
"BindYgg": false
|
"Password": "your_secure_password"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
@ -31,27 +33,20 @@ In the Yggdrasil configuration file:
|
||||||
- **`Enable`** - enable/disable WebUI
|
- **`Enable`** - enable/disable WebUI
|
||||||
- **`Port`** - port for web interface (default 9000)
|
- **`Port`** - port for web interface (default 9000)
|
||||||
- **`Host`** - IP address to bind to (empty means all interfaces)
|
- **`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
|
## Usage
|
||||||
|
|
||||||
### Standard mode
|
### Without password authentication
|
||||||
|
|
||||||
```go
|
```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
|
```go
|
||||||
server := webui.ServerWithCore("127.0.0.1:9000", logger, coreInstance)
|
server := webui.Server("127.0.0.1:9000", "your_password", logger)
|
||||||
```
|
|
||||||
|
|
||||||
### Automatic Yggdrasil address binding
|
|
||||||
|
|
||||||
```go
|
|
||||||
server := webui.ServerForYggdrasil(9000, logger, coreInstance)
|
|
||||||
// Automatically binds to [yggdrasil_ipv6]:9000
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Starting the server
|
### Starting the server
|
||||||
|
@ -69,9 +64,12 @@ server.Stop()
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
- **`/`** - main page (index.html)
|
- **`/`** - main page (index.html) - requires authentication if password is set
|
||||||
- **`/health`** - health check (returns "OK")
|
- **`/login.html`** - custom login page (only password required)
|
||||||
- **`/static/*`** - static files (CSS, JS, images)
|
- **`/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
|
## Build modes
|
||||||
|
|
||||||
|
@ -89,6 +87,10 @@ server.Stop()
|
||||||
- Configured HTTP timeouts
|
- Configured HTTP timeouts
|
||||||
- Header size limits
|
- Header size limits
|
||||||
- File MIME type validation
|
- 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
|
## 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
|
// Try to create server with this config
|
||||||
logger := createTestLogger()
|
logger := createTestLogger()
|
||||||
server := Server(listenAddr, logger)
|
server := Server(listenAddr, "", logger)
|
||||||
if server == nil {
|
if server == nil {
|
||||||
t.Error("Failed to create server with valid config")
|
t.Error("Failed to create server with valid config")
|
||||||
}
|
}
|
||||||
|
@ -152,7 +152,7 @@ func TestWebUIConfig_PortRanges(t *testing.T) {
|
||||||
for _, test := range portTests {
|
for _, test := range portTests {
|
||||||
t.Run(test.description, func(t *testing.T) {
|
t.Run(test.description, func(t *testing.T) {
|
||||||
listenAddr := fmt.Sprintf("127.0.0.1:%d", test.port)
|
listenAddr := fmt.Sprintf("127.0.0.1:%d", test.port)
|
||||||
server := Server(listenAddr, logger)
|
server := Server(listenAddr, "", logger)
|
||||||
|
|
||||||
if server == nil {
|
if server == nil {
|
||||||
t.Errorf("Failed to create server for %s", test.description)
|
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)
|
t.Errorf("Expected %s, got %s", test.expected, listenAddr)
|
||||||
}
|
}
|
||||||
|
|
||||||
server := Server(listenAddr, logger)
|
server := Server(listenAddr, "", logger)
|
||||||
if server == nil {
|
if server == nil {
|
||||||
t.Errorf("Failed to create server with %s", test.description)
|
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)
|
listenAddr := fmt.Sprintf("%s:%d", cfg.WebUI.Host, cfg.WebUI.Port)
|
||||||
|
|
||||||
logger := createTestLogger()
|
logger := createTestLogger()
|
||||||
server := Server(listenAddr, logger)
|
server := Server(listenAddr, "", logger)
|
||||||
|
|
||||||
if server == nil {
|
if server == nil {
|
||||||
t.Fatal("Failed to create server from generated config")
|
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) {
|
test: func(t *testing.T, cfg config.WebUIConfig) {
|
||||||
listenAddr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
listenAddr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
||||||
server := Server(listenAddr, logger)
|
server := Server(listenAddr, "", logger)
|
||||||
if server == nil {
|
if server == nil {
|
||||||
t.Error("Should be able to create server with max port")
|
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) {
|
test: func(t *testing.T, cfg config.WebUIConfig) {
|
||||||
listenAddr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
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
|
// Server creation should not panic, even with invalid host
|
||||||
if server == nil {
|
if server == nil {
|
||||||
t.Error("Server creation should not fail due to host format")
|
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
|
// Use httptest.Server for more reliable testing
|
||||||
mux := http.NewServeMux()
|
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) {
|
mux.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) {
|
||||||
serveFile(rw, r, logger)
|
serveFile(rw, r, logger)
|
||||||
})
|
})
|
||||||
|
@ -41,7 +41,7 @@ func TestWebUIServer_HealthEndpointDetails(t *testing.T) {
|
||||||
|
|
||||||
// Use httptest.Server for more reliable testing
|
// Use httptest.Server for more reliable testing
|
||||||
mux := http.NewServeMux()
|
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) {
|
mux.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) {
|
||||||
serveFile(rw, r, logger)
|
serveFile(rw, r, logger)
|
||||||
})
|
})
|
||||||
|
@ -103,7 +103,7 @@ func TestWebUIServer_NonExistentEndpoint(t *testing.T) {
|
||||||
|
|
||||||
// Use httptest.Server for more reliable testing
|
// Use httptest.Server for more reliable testing
|
||||||
mux := http.NewServeMux()
|
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) {
|
mux.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) {
|
||||||
serveFile(rw, r, logger)
|
serveFile(rw, r, logger)
|
||||||
})
|
})
|
||||||
|
@ -152,7 +152,7 @@ func TestWebUIServer_ContentTypes(t *testing.T) {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
// Setup handlers like in the actual server
|
// 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) {
|
mux.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) {
|
||||||
serveFile(rw, r, logger)
|
serveFile(rw, r, logger)
|
||||||
})
|
})
|
||||||
|
@ -182,7 +182,7 @@ func TestWebUIServer_HeaderSecurity(t *testing.T) {
|
||||||
|
|
||||||
// Use httptest.Server for more reliable testing
|
// Use httptest.Server for more reliable testing
|
||||||
mux := http.NewServeMux()
|
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) {
|
mux.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) {
|
||||||
serveFile(rw, r, logger)
|
serveFile(rw, r, logger)
|
||||||
})
|
})
|
||||||
|
@ -221,7 +221,7 @@ func TestWebUIServer_ConcurrentRequests(t *testing.T) {
|
||||||
|
|
||||||
// Use httptest.Server for more reliable testing
|
// Use httptest.Server for more reliable testing
|
||||||
mux := http.NewServeMux()
|
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) {
|
mux.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) {
|
||||||
serveFile(rw, r, logger)
|
serveFile(rw, r, logger)
|
||||||
})
|
})
|
||||||
|
|
|
@ -28,7 +28,7 @@ func TestWebUIServer_InvalidListenAddress(t *testing.T) {
|
||||||
|
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
t.Run(fmt.Sprintf("Address_%s_%s", tc.addr, tc.description), func(t *testing.T) {
|
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
|
// Use a timeout to prevent hanging on addresses that might partially work
|
||||||
done := make(chan error, 1)
|
done := make(chan error, 1)
|
||||||
|
@ -71,7 +71,7 @@ func TestWebUIServer_PortAlreadyInUse(t *testing.T) {
|
||||||
usedPort := listener.Addr().(*net.TCPAddr).Port
|
usedPort := listener.Addr().(*net.TCPAddr).Port
|
||||||
conflictAddress := fmt.Sprintf("127.0.0.1:%d", usedPort)
|
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
|
// This should fail because port is already in use
|
||||||
err = server.Start()
|
err = server.Start()
|
||||||
|
@ -85,8 +85,8 @@ func TestWebUIServer_DoubleStart(t *testing.T) {
|
||||||
logger := createTestLogger()
|
logger := createTestLogger()
|
||||||
|
|
||||||
// Create two separate servers to test behavior
|
// Create two separate servers to test behavior
|
||||||
server1 := Server("127.0.0.1:0", logger)
|
server1 := Server("127.0.0.1:0", "", logger)
|
||||||
server2 := Server("127.0.0.1:0", logger)
|
server2 := Server("127.0.0.1:0", "", logger)
|
||||||
|
|
||||||
// Start first server
|
// Start first server
|
||||||
startDone1 := make(chan error, 1)
|
startDone1 := make(chan error, 1)
|
||||||
|
@ -142,7 +142,7 @@ func TestWebUIServer_DoubleStart(t *testing.T) {
|
||||||
|
|
||||||
func TestWebUIServer_StopTwice(t *testing.T) {
|
func TestWebUIServer_StopTwice(t *testing.T) {
|
||||||
logger := createTestLogger()
|
logger := createTestLogger()
|
||||||
server := Server("127.0.0.1:0", logger)
|
server := Server("127.0.0.1:0", "", logger)
|
||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
go func() {
|
go func() {
|
||||||
|
@ -176,7 +176,7 @@ func TestWebUIServer_GracefulShutdown(t *testing.T) {
|
||||||
addr := listener.Addr().String()
|
addr := listener.Addr().String()
|
||||||
listener.Close() // Close so our server can use it
|
listener.Close() // Close so our server can use it
|
||||||
|
|
||||||
server := Server(addr, logger)
|
server := Server(addr, "", logger)
|
||||||
|
|
||||||
// Channel to track when Start() returns
|
// Channel to track when Start() returns
|
||||||
startDone := make(chan error, 1)
|
startDone := make(chan error, 1)
|
||||||
|
@ -230,7 +230,7 @@ func TestWebUIServer_GracefulShutdown(t *testing.T) {
|
||||||
|
|
||||||
func TestWebUIServer_ContextCancellation(t *testing.T) {
|
func TestWebUIServer_ContextCancellation(t *testing.T) {
|
||||||
logger := createTestLogger()
|
logger := createTestLogger()
|
||||||
server := Server("127.0.0.1:0", logger)
|
server := Server("127.0.0.1:0", "", logger)
|
||||||
|
|
||||||
// Create context with timeout
|
// Create context with timeout
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
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) {
|
func TestWebUIServer_LoggerNil(t *testing.T) {
|
||||||
// Test server creation with nil logger
|
// 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 {
|
if server == nil {
|
||||||
t.Fatal("Server should be created even with nil logger")
|
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
|
// Test rapid start/stop cycles with fewer iterations
|
||||||
for i := 0; i < 5; i++ {
|
for i := 0; i < 5; i++ {
|
||||||
server := Server("127.0.0.1:0", logger)
|
server := Server("127.0.0.1:0", "", logger)
|
||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
startDone := make(chan error, 1)
|
startDone := make(chan error, 1)
|
||||||
|
@ -306,7 +306,7 @@ func TestWebUIServer_LargeNumberOfRequests(t *testing.T) {
|
||||||
|
|
||||||
// Use httptest.Server for more reliable testing
|
// Use httptest.Server for more reliable testing
|
||||||
mux := http.NewServeMux()
|
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) {
|
mux.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) {
|
||||||
serveFile(rw, r, logger)
|
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
|
package webui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/yggdrasil-network/yggdrasil-go/src/core"
|
"github.com/yggdrasil-network/yggdrasil-go/src/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
type WebUIServer struct {
|
type WebUIServer struct {
|
||||||
server *http.Server
|
server *http.Server
|
||||||
log core.Logger
|
log core.Logger
|
||||||
listen string
|
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{
|
return &WebUIServer{
|
||||||
listen: listen,
|
listen: listen,
|
||||||
log: log,
|
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 {
|
func (w *WebUIServer) Start() error {
|
||||||
// Validate listen address before starting
|
// Validate listen address before starting
|
||||||
if w.listen != "" {
|
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()
|
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)
|
// Setup static files handler (implementation varies by build)
|
||||||
setupStaticHandler(mux)
|
setupStaticHandler(mux, w)
|
||||||
|
|
||||||
// Serve any file by path (implementation varies by build)
|
// Serve any file by path (implementation varies by build) - with auth
|
||||||
mux.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/", w.authMiddleware(func(rw http.ResponseWriter, r *http.Request) {
|
||||||
serveFile(rw, r, w.log)
|
serveFile(rw, r, w.log)
|
||||||
})
|
}))
|
||||||
|
|
||||||
// Health check endpoint
|
// Health check endpoint - no auth required
|
||||||
mux.HandleFunc("/health", func(rw http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/health", func(rw http.ResponseWriter, r *http.Request) {
|
||||||
rw.WriteHeader(http.StatusOK)
|
rw.WriteHeader(http.StatusOK)
|
||||||
_, _ = rw.Write([]byte("OK"))
|
_, _ = rw.Write([]byte("OK"))
|
||||||
|
|
|
@ -14,9 +14,12 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// setupStaticHandler configures static file serving for development (files from disk)
|
// setupStaticHandler configures static file serving for development (files from disk)
|
||||||
func setupStaticHandler(mux *http.ServeMux) {
|
func setupStaticHandler(mux *http.ServeMux, server *WebUIServer) {
|
||||||
// Serve static files from disk for development
|
// Serve static files from disk for development - with auth
|
||||||
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("src/webui/static/"))))
|
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
|
// serveFile serves any file from disk or returns 404 if not found
|
||||||
|
|
|
@ -18,15 +18,19 @@ import (
|
||||||
var staticFiles embed.FS
|
var staticFiles embed.FS
|
||||||
|
|
||||||
// setupStaticHandler configures static file serving for production (embedded files)
|
// 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
|
// Get the embedded file system for static files
|
||||||
staticFS, err := fs.Sub(staticFiles, "static")
|
staticFS, err := fs.Sub(staticFiles, "static")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic("failed to get embedded static files: " + err.Error())
|
panic("failed to get embedded static files: " + err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Serve static files from embedded FS
|
// Serve static files from embedded FS - with auth
|
||||||
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
|
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
|
// 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()
|
logger := createTestLogger()
|
||||||
listen := getTestAddress()
|
listen := getTestAddress()
|
||||||
|
|
||||||
server := Server(listen, logger)
|
server := Server(listen, "", logger)
|
||||||
|
|
||||||
if server == nil {
|
if server == nil {
|
||||||
t.Fatal("Server function returned nil")
|
t.Fatal("Server function returned nil")
|
||||||
|
@ -49,7 +49,7 @@ func TestWebUIServer_StartStop(t *testing.T) {
|
||||||
logger := createTestLogger()
|
logger := createTestLogger()
|
||||||
listen := getTestAddress()
|
listen := getTestAddress()
|
||||||
|
|
||||||
server := Server(listen, logger)
|
server := Server(listen, "", logger)
|
||||||
|
|
||||||
// Start server in goroutine
|
// Start server in goroutine
|
||||||
errChan := make(chan error, 1)
|
errChan := make(chan error, 1)
|
||||||
|
@ -86,7 +86,7 @@ func TestWebUIServer_StopWithoutStart(t *testing.T) {
|
||||||
logger := createTestLogger()
|
logger := createTestLogger()
|
||||||
listen := getTestAddress()
|
listen := getTestAddress()
|
||||||
|
|
||||||
server := Server(listen, logger)
|
server := Server(listen, "", logger)
|
||||||
|
|
||||||
// Stop server that was never started should not error
|
// Stop server that was never started should not error
|
||||||
err := server.Stop()
|
err := server.Stop()
|
||||||
|
@ -100,7 +100,8 @@ func TestWebUIServer_HealthEndpoint(t *testing.T) {
|
||||||
|
|
||||||
// Create a test server using net/http/httptest for reliable testing
|
// Create a test server using net/http/httptest for reliable testing
|
||||||
mux := http.NewServeMux()
|
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) {
|
mux.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) {
|
||||||
serveFile(rw, r, logger)
|
serveFile(rw, r, logger)
|
||||||
})
|
})
|
||||||
|
@ -135,7 +136,7 @@ func TestWebUIServer_HealthEndpoint(t *testing.T) {
|
||||||
|
|
||||||
func TestWebUIServer_Timeouts(t *testing.T) {
|
func TestWebUIServer_Timeouts(t *testing.T) {
|
||||||
logger := createTestLogger()
|
logger := createTestLogger()
|
||||||
server := Server("127.0.0.1:0", logger)
|
server := Server("127.0.0.1:0", "", logger)
|
||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
go func() {
|
go func() {
|
||||||
|
@ -173,7 +174,7 @@ func TestWebUIServer_ConcurrentStartStop(t *testing.T) {
|
||||||
|
|
||||||
// Test concurrent start/stop operations with separate servers
|
// Test concurrent start/stop operations with separate servers
|
||||||
for i := 0; i < 3; i++ {
|
for i := 0; i < 3; i++ {
|
||||||
server := Server("127.0.0.1:0", logger)
|
server := Server("127.0.0.1:0", "", logger)
|
||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
startDone := make(chan error, 1)
|
startDone := make(chan error, 1)
|
||||||
|
|
|
@ -11,8 +11,15 @@
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<header>
|
<header>
|
||||||
<h1>🌳 Yggdrasil Web Interface</h1>
|
<div class="header-content">
|
||||||
<p>Network mesh management dashboard</p>
|
<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>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
|
@ -50,6 +57,14 @@
|
||||||
<p>Yggdrasil Network • Minimal WebUI v1.0</p>
|
<p>Yggdrasil Network • Minimal WebUI v1.0</p>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function logout() {
|
||||||
|
if (confirm('Are you sure you want to logout?')) {
|
||||||
|
window.location.href = '/auth/logout';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</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 {
|
header {
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 40px;
|
margin-bottom: 40px;
|
||||||
color: white;
|
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 {
|
header h1 {
|
||||||
font-size: 2.5rem;
|
font-size: 2.5rem;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
|
@ -34,6 +45,21 @@ header p {
|
||||||
opacity: 0.9;
|
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 {
|
main {
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
|
@ -122,6 +148,15 @@ footer {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content > div:first-child {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
header h1 {
|
header h1 {
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
}
|
}
|
||||||
|
|
|
@ -59,7 +59,7 @@ func TestStaticFiles_ProdMode_SetupStaticHandler(t *testing.T) {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
// This should not panic
|
// This should not panic
|
||||||
setupStaticHandler(mux)
|
testServer := Server("127.0.0.1:0", "", createTestLogger()); setupStaticHandler(mux, testServer)
|
||||||
|
|
||||||
server := httptest.NewServer(mux)
|
server := httptest.NewServer(mux)
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
|
@ -128,7 +128,7 @@ func TestStaticFiles_DevMode_SetupStaticHandler(t *testing.T) {
|
||||||
|
|
||||||
// Create HTTP server with static handler
|
// Create HTTP server with static handler
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
setupStaticHandler(mux)
|
testServer := Server("127.0.0.1:0", "", createTestLogger()); setupStaticHandler(mux, testServer)
|
||||||
|
|
||||||
server := httptest.NewServer(mux)
|
server := httptest.NewServer(mux)
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue