487 lines
14 KiB
Go
487 lines
14 KiB
Go
|
|
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)
|
||
|
|
}
|