Files
autarch/services/dns-server/web/webui.go

487 lines
14 KiB
Go
Raw Normal View History

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)
}