mirror of
https://github.com/yggdrasil-network/yggdrasil-go.git
synced 2025-08-24 16:05:07 +03:00
609 lines
16 KiB
Go
609 lines
16 KiB
Go
package webui
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/subtle"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/yggdrasil-network/yggdrasil-go/src/admin"
|
|
"github.com/yggdrasil-network/yggdrasil-go/src/config"
|
|
"github.com/yggdrasil-network/yggdrasil-go/src/core"
|
|
)
|
|
|
|
type WebUIServer struct {
|
|
server *http.Server
|
|
log core.Logger
|
|
listen string
|
|
password string
|
|
sessions map[string]time.Time // sessionID -> expiry time
|
|
sessionsMux sync.RWMutex
|
|
failedAttempts map[string]*FailedLoginInfo // IP -> failed login info
|
|
attemptsMux sync.RWMutex
|
|
admin *admin.AdminSocket // Admin socket reference for direct API calls
|
|
}
|
|
|
|
type LoginRequest struct {
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
type FailedLoginInfo struct {
|
|
Count int
|
|
LastAttempt time.Time
|
|
BlockedUntil time.Time
|
|
}
|
|
|
|
const (
|
|
MaxFailedAttempts = 3
|
|
BlockDuration = 1 * time.Minute
|
|
AttemptWindow = 15 * time.Minute // Reset counter if no attempts in 15 minutes
|
|
)
|
|
|
|
func Server(listen string, password string, log core.Logger) *WebUIServer {
|
|
return &WebUIServer{
|
|
listen: listen,
|
|
password: password,
|
|
log: log,
|
|
sessions: make(map[string]time.Time),
|
|
failedAttempts: make(map[string]*FailedLoginInfo),
|
|
admin: nil, // Will be set later via SetAdmin
|
|
}
|
|
}
|
|
|
|
// SetAdmin sets the admin socket reference for direct API calls
|
|
func (w *WebUIServer) SetAdmin(admin *admin.AdminSocket) {
|
|
w.admin = admin
|
|
}
|
|
|
|
// generateSessionID creates a random session ID
|
|
func (w *WebUIServer) generateSessionID() string {
|
|
bytes := make([]byte, 32)
|
|
if _, err := rand.Read(bytes); err != nil {
|
|
// Fallback to timestamp-based ID if random generation fails
|
|
return hex.EncodeToString([]byte(fmt.Sprintf("%d", time.Now().UnixNano())))
|
|
}
|
|
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
|
|
}
|
|
|
|
// getClientIP extracts the real client IP from request
|
|
func (w *WebUIServer) getClientIP(r *http.Request) string {
|
|
// Check for forwarded IP headers (for reverse proxies)
|
|
forwarded := r.Header.Get("X-Forwarded-For")
|
|
if forwarded != "" {
|
|
// Take the first IP in the chain
|
|
ips := strings.Split(forwarded, ",")
|
|
return strings.TrimSpace(ips[0])
|
|
}
|
|
|
|
realIP := r.Header.Get("X-Real-IP")
|
|
if realIP != "" {
|
|
return realIP
|
|
}
|
|
|
|
// Extract IP from RemoteAddr
|
|
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
|
|
return ip
|
|
}
|
|
|
|
// isIPBlocked checks if an IP address is currently blocked
|
|
func (w *WebUIServer) isIPBlocked(ip string) bool {
|
|
w.attemptsMux.RLock()
|
|
defer w.attemptsMux.RUnlock()
|
|
|
|
info, exists := w.failedAttempts[ip]
|
|
if !exists {
|
|
return false
|
|
}
|
|
|
|
return time.Now().Before(info.BlockedUntil)
|
|
}
|
|
|
|
// recordFailedAttempt records a failed login attempt for an IP
|
|
func (w *WebUIServer) recordFailedAttempt(ip string) {
|
|
w.attemptsMux.Lock()
|
|
defer w.attemptsMux.Unlock()
|
|
|
|
now := time.Now()
|
|
info, exists := w.failedAttempts[ip]
|
|
|
|
if !exists {
|
|
info = &FailedLoginInfo{}
|
|
w.failedAttempts[ip] = info
|
|
}
|
|
|
|
// Reset counter if last attempt was too long ago
|
|
if now.Sub(info.LastAttempt) > AttemptWindow {
|
|
info.Count = 0
|
|
}
|
|
|
|
info.Count++
|
|
info.LastAttempt = now
|
|
|
|
// Block IP if too many failed attempts
|
|
if info.Count >= MaxFailedAttempts {
|
|
info.BlockedUntil = now.Add(BlockDuration)
|
|
w.log.Warnf("IP %s blocked for %v after %d failed login attempts", ip, BlockDuration, info.Count)
|
|
}
|
|
}
|
|
|
|
// clearFailedAttempts clears failed attempts for an IP (on successful login)
|
|
func (w *WebUIServer) clearFailedAttempts(ip string) {
|
|
w.attemptsMux.Lock()
|
|
defer w.attemptsMux.Unlock()
|
|
|
|
delete(w.failedAttempts, ip)
|
|
}
|
|
|
|
// cleanupFailedAttempts removes old failed attempt records
|
|
func (w *WebUIServer) cleanupFailedAttempts() {
|
|
w.attemptsMux.Lock()
|
|
defer w.attemptsMux.Unlock()
|
|
|
|
now := time.Now()
|
|
for ip, info := range w.failedAttempts {
|
|
// Remove if block period has expired and no recent attempts
|
|
if now.After(info.BlockedUntil) && now.Sub(info.LastAttempt) > AttemptWindow {
|
|
delete(w.failedAttempts, ip)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
// If no password is set and user tries to access login page, redirect to main page
|
|
if w.password == "" {
|
|
if r.URL.Path == "/login.html" {
|
|
http.Redirect(rw, r, "/", http.StatusSeeOther)
|
|
return
|
|
}
|
|
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 with brute force protection
|
|
func (w *WebUIServer) loginHandler(rw http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
clientIP := w.getClientIP(r)
|
|
|
|
// Check if IP is blocked
|
|
if w.isIPBlocked(clientIP) {
|
|
w.log.Warnf("Blocked login attempt from %s (IP is temporarily blocked)", clientIP)
|
|
http.Error(rw, "Too many failed attempts. Please try again later.", http.StatusTooManyRequests)
|
|
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", clientIP)
|
|
w.recordFailedAttempt(clientIP)
|
|
http.Error(rw, "Invalid password", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
// Successful login - clear any failed attempts
|
|
w.clearFailedAttempts(clientIP)
|
|
|
|
// 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.Infof("Successful authentication for IP %s", clientIP)
|
|
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)
|
|
}
|
|
|
|
// adminAPIHandler handles direct admin API calls
|
|
func (w *WebUIServer) adminAPIHandler(rw http.ResponseWriter, r *http.Request) {
|
|
if w.admin == nil {
|
|
http.Error(rw, "Admin API not available", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
// Extract command from URL path
|
|
// /api/admin/getSelf -> getSelf
|
|
path := strings.TrimPrefix(r.URL.Path, "/api/admin/")
|
|
command := strings.Split(path, "/")[0]
|
|
|
|
if command == "" {
|
|
// Return list of available commands
|
|
commands := w.admin.GetAvailableCommands()
|
|
rw.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(rw).Encode(map[string]interface{}{
|
|
"status": "success",
|
|
"commands": commands,
|
|
}); err != nil {
|
|
http.Error(rw, "Failed to encode response", http.StatusInternalServerError)
|
|
}
|
|
return
|
|
}
|
|
|
|
var args map[string]interface{}
|
|
if r.Method == http.MethodPost {
|
|
if err := json.NewDecoder(r.Body).Decode(&args); err != nil {
|
|
args = make(map[string]interface{})
|
|
}
|
|
} else {
|
|
args = make(map[string]interface{})
|
|
}
|
|
|
|
// Call admin handler directly
|
|
result, err := w.callAdminHandler(command, args)
|
|
if err != nil {
|
|
rw.Header().Set("Content-Type", "application/json")
|
|
rw.WriteHeader(http.StatusBadRequest)
|
|
if encErr := json.NewEncoder(rw).Encode(map[string]interface{}{
|
|
"status": "error",
|
|
"error": err.Error(),
|
|
}); encErr != nil {
|
|
http.Error(rw, "Failed to encode error response", http.StatusInternalServerError)
|
|
}
|
|
return
|
|
}
|
|
|
|
rw.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(rw).Encode(map[string]interface{}{
|
|
"status": "success",
|
|
"response": result,
|
|
}); err != nil {
|
|
http.Error(rw, "Failed to encode response", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// callAdminHandler calls admin handlers directly without socket
|
|
func (w *WebUIServer) callAdminHandler(command string, args map[string]interface{}) (interface{}, error) {
|
|
argsBytes, err := json.Marshal(args)
|
|
if err != nil {
|
|
argsBytes = []byte("{}")
|
|
}
|
|
|
|
return w.admin.CallHandler(command, argsBytes)
|
|
}
|
|
|
|
// Configuration response structures
|
|
type ConfigResponse struct {
|
|
ConfigPath string `json:"config_path"`
|
|
ConfigFormat string `json:"config_format"`
|
|
ConfigJSON string `json:"config_json"`
|
|
IsWritable bool `json:"is_writable"`
|
|
}
|
|
|
|
type ConfigSetRequest struct {
|
|
ConfigJSON string `json:"config_json"`
|
|
ConfigPath string `json:"config_path,omitempty"`
|
|
Format string `json:"format,omitempty"`
|
|
Restart bool `json:"restart,omitempty"`
|
|
}
|
|
|
|
type ConfigSetResponse struct {
|
|
Success bool `json:"success"`
|
|
Message string `json:"message"`
|
|
ConfigPath string `json:"config_path"`
|
|
BackupPath string `json:"backup_path,omitempty"`
|
|
RestartRequired bool `json:"restart_required"`
|
|
}
|
|
|
|
// getConfigHandler handles configuration file reading
|
|
func (w *WebUIServer) getConfigHandler(rw http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
// Use config package to get current configuration
|
|
configInfo, err := config.GetCurrentConfig()
|
|
if err != nil {
|
|
w.log.Errorf("Failed to get current config: %v", err)
|
|
http.Error(rw, "Failed to get configuration", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Convert config data to formatted JSON string
|
|
configBytes, err := json.MarshalIndent(configInfo.Data, "", " ")
|
|
if err != nil {
|
|
w.log.Errorf("Failed to marshal config to JSON: %v", err)
|
|
http.Error(rw, "Failed to format configuration", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
response := ConfigResponse{
|
|
ConfigPath: configInfo.Path,
|
|
ConfigFormat: configInfo.Format,
|
|
ConfigJSON: string(configBytes),
|
|
IsWritable: configInfo.Writable,
|
|
}
|
|
|
|
rw.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(rw).Encode(response); err != nil {
|
|
w.log.Errorf("Failed to encode config response: %v", err)
|
|
http.Error(rw, "Failed to encode response", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// setConfigHandler handles configuration file writing
|
|
func (w *WebUIServer) setConfigHandler(rw http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
var req ConfigSetRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(rw, "Invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Parse JSON configuration
|
|
var configData interface{}
|
|
if err := json.Unmarshal([]byte(req.ConfigJSON), &configData); err != nil {
|
|
response := ConfigSetResponse{
|
|
Success: false,
|
|
Message: fmt.Sprintf("Invalid JSON configuration: %v", err),
|
|
}
|
|
w.writeJSONResponse(rw, response)
|
|
return
|
|
}
|
|
|
|
// Use config package to save configuration
|
|
err := config.SaveConfig(configData, req.ConfigPath, req.Format)
|
|
if err != nil {
|
|
response := ConfigSetResponse{
|
|
Success: false,
|
|
Message: err.Error(),
|
|
}
|
|
w.writeJSONResponse(rw, response)
|
|
return
|
|
}
|
|
|
|
// Get current config info for response
|
|
configInfo, err := config.GetCurrentConfig()
|
|
var configPath string = req.ConfigPath
|
|
if err == nil && configInfo != nil {
|
|
configPath = configInfo.Path
|
|
}
|
|
|
|
response := ConfigSetResponse{
|
|
Success: true,
|
|
Message: "Configuration saved successfully",
|
|
ConfigPath: configPath,
|
|
BackupPath: configPath + ".backup",
|
|
RestartRequired: req.Restart,
|
|
}
|
|
|
|
// If restart is requested, trigger server restart
|
|
if req.Restart {
|
|
w.log.Infof("Configuration saved with restart request")
|
|
go w.restartServer()
|
|
}
|
|
|
|
w.writeJSONResponse(rw, response)
|
|
}
|
|
|
|
// writeJSONResponse helper function
|
|
func (w *WebUIServer) writeJSONResponse(rw http.ResponseWriter, data interface{}) {
|
|
rw.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(rw).Encode(data); err != nil {
|
|
w.log.Errorf("Failed to encode JSON response: %v", err)
|
|
http.Error(rw, "Failed to encode response", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
func (w *WebUIServer) Start() error {
|
|
// Validate listen address before starting
|
|
if w.listen != "" {
|
|
if _, _, err := net.SplitHostPort(w.listen); err != nil {
|
|
return fmt.Errorf("invalid listen address: %v", err)
|
|
}
|
|
}
|
|
|
|
// Start cleanup routines
|
|
go func() {
|
|
sessionTicker := time.NewTicker(1 * time.Hour)
|
|
attemptsTicker := time.NewTicker(5 * time.Minute) // Clean failed attempts more frequently
|
|
defer sessionTicker.Stop()
|
|
defer attemptsTicker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-sessionTicker.C:
|
|
w.cleanupExpiredSessions()
|
|
case <-attemptsTicker.C:
|
|
w.cleanupFailedAttempts()
|
|
}
|
|
}
|
|
}()
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
// Authentication endpoints - no auth required
|
|
mux.HandleFunc("/auth/login", w.loginHandler)
|
|
mux.HandleFunc("/auth/logout", w.logoutHandler)
|
|
|
|
// Admin API endpoints - with auth
|
|
mux.HandleFunc("/api/admin/", w.authMiddleware(w.adminAPIHandler))
|
|
|
|
// Configuration API endpoints - with auth
|
|
mux.HandleFunc("/api/config/get", w.authMiddleware(w.getConfigHandler))
|
|
mux.HandleFunc("/api/config/set", w.authMiddleware(w.setConfigHandler))
|
|
|
|
// Setup static files handler (implementation varies by build)
|
|
setupStaticHandler(mux, w)
|
|
|
|
// 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 - no auth required
|
|
mux.HandleFunc("/health", func(rw http.ResponseWriter, r *http.Request) {
|
|
rw.WriteHeader(http.StatusOK)
|
|
_, _ = rw.Write([]byte("OK"))
|
|
})
|
|
|
|
w.server = &http.Server{
|
|
Addr: w.listen,
|
|
Handler: mux,
|
|
ReadTimeout: 10 * time.Second,
|
|
WriteTimeout: 10 * time.Second,
|
|
MaxHeaderBytes: 1 << 20,
|
|
}
|
|
|
|
w.log.Infof("WebUI server starting on %s", w.listen)
|
|
|
|
if err := w.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
return fmt.Errorf("WebUI server failed: %v", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (w *WebUIServer) Stop() error {
|
|
if w.server != nil {
|
|
return w.server.Close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// restartServer triggers a graceful restart of the Yggdrasil process
|
|
func (w *WebUIServer) restartServer() {
|
|
w.log.Infof("Initiating server restart after configuration change")
|
|
|
|
// Give some time for the response to be sent
|
|
time.Sleep(1 * time.Second)
|
|
|
|
// Cross-platform restart handling
|
|
proc, err := os.FindProcess(os.Getpid())
|
|
if err != nil {
|
|
w.log.Errorf("Failed to find current process: %v", err)
|
|
return
|
|
}
|
|
|
|
// Try to send restart signal (platform-specific)
|
|
if err := sendRestartSignal(proc); err != nil {
|
|
w.log.Errorf("Failed to send restart signal: %v", err)
|
|
w.log.Infof("Please restart Yggdrasil manually to apply configuration changes")
|
|
}
|
|
}
|