package web import ( "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/hex" "encoding/json" "fmt" "log" "net/http" "os" "path/filepath" "strings" "sync" "time" ) // WebUIConfig holds authentication and server settings. type WebUIConfig struct { Listen string `json:"listen"` // e.g. "0.0.0.0:8443" Username string `json:"username"` PasswordHash string `json:"password_hash"` // SHA-256 hex WebRoot string `json:"webroot"` // static files directory APIBackend string `json:"api_backend"` // internal API URL APIToken string `json:"api_token"` // token for API auth TLSCert string `json:"tls_cert"` // path to TLS certificate TLSKey string `json:"tls_key"` // path to TLS private key SessionTTL int `json:"session_ttl"` // session lifetime in seconds } // Session tracks an authenticated user session. type Session struct { ID string Username string CreatedAt time.Time ExpiresAt time.Time IP string } // WebUIServer serves the management interface. type WebUIServer struct { cfg WebUIConfig sessions map[string]*Session mu sync.RWMutex // Brute force protection failedAttempts map[string]int lockouts map[string]time.Time failMu sync.Mutex } // NewWebUIServer creates a new WebUI server. func NewWebUIServer(cfg WebUIConfig) *WebUIServer { if cfg.SessionTTL <= 0 { cfg.SessionTTL = 3600 // 1 hour default } return &WebUIServer{ cfg: cfg, sessions: make(map[string]*Session), failedAttempts: make(map[string]int), lockouts: make(map[string]time.Time), } } // HashPassword returns SHA-256 hex hash of a password. func HashPassword(password string) string { h := sha256.Sum256([]byte(password)) return hex.EncodeToString(h[:]) } // Start begins the WebUI HTTP(S) server. func (s *WebUIServer) Start() error { mux := http.NewServeMux() // Auth endpoints mux.HandleFunc("/auth/login", s.handleLogin) mux.HandleFunc("/auth/logout", s.handleLogout) mux.HandleFunc("/auth/check", s.handleAuthCheck) // API proxy — all /api/* requests are forwarded to the DNS API backend mux.HandleFunc("/api/", s.requireAuth(s.handleAPIProxy)) // Nameserver config (stored locally by WebUI, not in DNS server) mux.HandleFunc("/webui/nameservers", s.requireAuth(s.handleNameservers)) // Static files (login page doesn't need auth, app does via JS) mux.HandleFunc("/", s.handleStatic) handler := s.securityHeaders(mux) // Session cleanup goroutine go s.sessionCleanup() if s.cfg.TLSCert != "" && s.cfg.TLSKey != "" { log.Printf("[webui] HTTPS server listening on %s", s.cfg.Listen) return http.ListenAndServeTLS(s.cfg.Listen, s.cfg.TLSCert, s.cfg.TLSKey, handler) } log.Printf("[webui] HTTP server listening on %s (no TLS)", s.cfg.Listen) return http.ListenAndServe(s.cfg.Listen, handler) } // ── Security middleware ───────────────────────────────────────────── func (s *WebUIServer) securityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("X-XSS-Protection", "1; mode=block") w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'") w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") // Prevent caching of authenticated content if strings.HasPrefix(r.URL.Path, "/api/") { w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate") w.Header().Set("Pragma", "no-cache") } next.ServeHTTP(w, r) }) } // ── Auth handlers ─────────────────────────────────────────────────── func (s *WebUIServer) handleLogin(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } clientIP := extractIP(r) // Check lockout s.failMu.Lock() if lockUntil, ok := s.lockouts[clientIP]; ok && time.Now().Before(lockUntil) { s.failMu.Unlock() remaining := time.Until(lockUntil).Seconds() jsonResp(w, http.StatusTooManyRequests, map[string]interface{}{ "ok": false, "error": fmt.Sprintf("Too many failed attempts. Try again in %.0f seconds.", remaining), }) return } s.failMu.Unlock() var req struct { Username string `json:"username"` Password string `json:"password"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonResp(w, http.StatusBadRequest, map[string]interface{}{"ok": false, "error": "invalid request"}) return } // Constant-time comparison for both username and password usernameMatch := subtle.ConstantTimeCompare([]byte(req.Username), []byte(s.cfg.Username)) == 1 passwordHash := HashPassword(req.Password) passwordMatch := subtle.ConstantTimeCompare([]byte(passwordHash), []byte(s.cfg.PasswordHash)) == 1 if !usernameMatch || !passwordMatch { s.failMu.Lock() s.failedAttempts[clientIP]++ attempts := s.failedAttempts[clientIP] if attempts >= 5 { // Exponential lockout: 30s, 60s, 120s, 240s... lockDuration := time.Duration(30*(1<<(attempts-5))) * time.Second if lockDuration > 30*time.Minute { lockDuration = 30 * time.Minute } s.lockouts[clientIP] = time.Now().Add(lockDuration) log.Printf("[webui] SECURITY: IP %s locked out for %v after %d failed attempts", clientIP, lockDuration, attempts) } s.failMu.Unlock() log.Printf("[webui] SECURITY: Failed login from %s (user=%q, attempt=%d)", clientIP, req.Username, attempts) jsonResp(w, http.StatusUnauthorized, map[string]interface{}{"ok": false, "error": "invalid credentials"}) return } // Success — clear failed attempts s.failMu.Lock() delete(s.failedAttempts, clientIP) delete(s.lockouts, clientIP) s.failMu.Unlock() // Create session sessionID := generateSessionID() session := &Session{ ID: sessionID, Username: req.Username, CreatedAt: time.Now(), ExpiresAt: time.Now().Add(time.Duration(s.cfg.SessionTTL) * time.Second), IP: clientIP, } s.mu.Lock() s.sessions[sessionID] = session s.mu.Unlock() log.Printf("[webui] Login: user=%s from %s", req.Username, clientIP) // Set secure cookie http.SetCookie(w, &http.Cookie{ Name: "session", Value: sessionID, Path: "/", HttpOnly: true, Secure: s.cfg.TLSCert != "", SameSite: http.SameSiteStrictMode, MaxAge: s.cfg.SessionTTL, }) jsonResp(w, http.StatusOK, map[string]interface{}{"ok": true, "username": req.Username}) } func (s *WebUIServer) handleLogout(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie("session") if err == nil { s.mu.Lock() delete(s.sessions, cookie.Value) s.mu.Unlock() } http.SetCookie(w, &http.Cookie{ Name: "session", Value: "", Path: "/", HttpOnly: true, MaxAge: -1, }) jsonResp(w, http.StatusOK, map[string]interface{}{"ok": true}) } func (s *WebUIServer) handleAuthCheck(w http.ResponseWriter, r *http.Request) { session := s.getSession(r) if session == nil { jsonResp(w, http.StatusUnauthorized, map[string]interface{}{"ok": false}) return } jsonResp(w, http.StatusOK, map[string]interface{}{ "ok": true, "username": session.Username, "expires": session.ExpiresAt.Format(time.RFC3339), }) } // ── Session management ────────────────────────────────────────────── func (s *WebUIServer) getSession(r *http.Request) *Session { cookie, err := r.Cookie("session") if err != nil { return nil } s.mu.RLock() session, ok := s.sessions[cookie.Value] s.mu.RUnlock() if !ok || time.Now().After(session.ExpiresAt) { return nil } // Optionally validate IP hasn't changed (session fixation protection) clientIP := extractIP(r) if session.IP != clientIP { log.Printf("[webui] SECURITY: Session IP mismatch: session=%s, request=%s", session.IP, clientIP) return nil } return session } func (s *WebUIServer) requireAuth(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if s.getSession(r) == nil { jsonResp(w, http.StatusUnauthorized, map[string]interface{}{"ok": false, "error": "unauthorized"}) return } next(w, r) } } func (s *WebUIServer) sessionCleanup() { ticker := time.NewTicker(5 * time.Minute) defer ticker.Stop() for range ticker.C { now := time.Now() s.mu.Lock() for id, session := range s.sessions { if now.After(session.ExpiresAt) { delete(s.sessions, id) } } s.mu.Unlock() // Clean up old lockouts s.failMu.Lock() for ip, until := range s.lockouts { if now.After(until) { delete(s.lockouts, ip) delete(s.failedAttempts, ip) } } s.failMu.Unlock() } } // ── API Proxy ─────────────────────────────────────────────────────── func (s *WebUIServer) handleAPIProxy(w http.ResponseWriter, r *http.Request) { // Forward to internal API, injecting the API token backend := s.cfg.APIBackend if backend == "" { backend = "http://127.0.0.1:5380" } targetURL := backend + r.URL.Path if r.URL.RawQuery != "" { targetURL += "?" + r.URL.RawQuery } proxyReq, err := http.NewRequest(r.Method, targetURL, r.Body) if err != nil { jsonResp(w, http.StatusBadGateway, map[string]interface{}{"ok": false, "error": "proxy error"}) return } proxyReq.Header.Set("Authorization", "Bearer "+s.cfg.APIToken) proxyReq.Header.Set("Content-Type", r.Header.Get("Content-Type")) client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(proxyReq) if err != nil { jsonResp(w, http.StatusBadGateway, map[string]interface{}{"ok": false, "error": "backend unreachable"}) return } defer resp.Body.Close() w.Header().Set("Content-Type", "application/json") w.WriteHeader(resp.StatusCode) buf := make([]byte, 32768) for { n, err := resp.Body.Read(buf) if n > 0 { w.Write(buf[:n]) } if err != nil { break } } } // ── Nameserver config (local to WebUI) ────────────────────────────── type NameserverConfig struct { Nameservers [6]string `json:"nameservers"` Primary string `json:"primary"` // ns1.seteclabs.io etc Domain string `json:"domain"` } func (s *WebUIServer) handleNameservers(w http.ResponseWriter, r *http.Request) { nsFile := filepath.Join(filepath.Dir(s.cfg.WebRoot), "data", "nameservers.json") switch r.Method { case "GET": data, err := os.ReadFile(nsFile) if err != nil { // Return defaults jsonResp(w, http.StatusOK, map[string]interface{}{ "ok": true, "nameservers": NameserverConfig{ Nameservers: [6]string{ "1.1.1.1", "1.0.0.1", "8.8.8.8", "8.8.4.4", "9.9.9.9", "149.112.112.112", }, Primary: "ns1.seteclabs.io", Domain: "seteclabs.io", }, }) return } var cfg NameserverConfig json.Unmarshal(data, &cfg) jsonResp(w, http.StatusOK, map[string]interface{}{"ok": true, "nameservers": cfg}) case "PUT": var cfg NameserverConfig if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil { jsonResp(w, http.StatusBadRequest, map[string]interface{}{"ok": false, "error": "invalid JSON"}) return } // Also push upstream config to the DNS server upstreams := make([]string, 0) for _, ns := range cfg.Nameservers { ns = strings.TrimSpace(ns) if ns != "" { if !strings.Contains(ns, ":") { ns += ":53" } upstreams = append(upstreams, ns) } } // Save locally os.MkdirAll(filepath.Dir(nsFile), 0755) data, _ := json.MarshalIndent(cfg, "", " ") os.WriteFile(nsFile, data, 0600) // Push to DNS server config configPayload, _ := json.Marshal(map[string]interface{}{ "upstream": upstreams, }) req, _ := http.NewRequest("PUT", s.cfg.APIBackend+"/api/config", strings.NewReader(string(configPayload))) req.Header.Set("Authorization", "Bearer "+s.cfg.APIToken) req.Header.Set("Content-Type", "application/json") client := &http.Client{Timeout: 5 * time.Second} client.Do(req) jsonResp(w, http.StatusOK, map[string]interface{}{"ok": true, "nameservers": cfg}) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } // ── Static file serving ───────────────────────────────────────────── func (s *WebUIServer) handleStatic(w http.ResponseWriter, r *http.Request) { path := r.URL.Path if path == "/" { path = "/index.html" } // Prevent directory traversal clean := filepath.Clean(path) if strings.Contains(clean, "..") { http.Error(w, "forbidden", http.StatusForbidden) return } fullPath := filepath.Join(s.cfg.WebRoot, clean) // Check file exists if _, err := os.Stat(fullPath); os.IsNotExist(err) { // SPA fallback — serve index.html for non-file paths fullPath = filepath.Join(s.cfg.WebRoot, "index.html") } http.ServeFile(w, r, fullPath) } // ── Helpers ───────────────────────────────────────────────────────── func generateSessionID() string { b := make([]byte, 32) rand.Read(b) return hex.EncodeToString(b) } func extractIP(r *http.Request) string { // Check X-Forwarded-For first (behind reverse proxy) if xff := r.Header.Get("X-Forwarded-For"); xff != "" { parts := strings.Split(xff, ",") return strings.TrimSpace(parts[0]) } if xri := r.Header.Get("X-Real-IP"); xri != "" { return xri } ip := r.RemoteAddr if idx := strings.LastIndex(ip, ":"); idx > 0 { ip = ip[:idx] } return strings.Trim(ip, "[]") } func jsonResp(w http.ResponseWriter, status int, data interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(data) }