Add Android forensics, IOC threat-intel DB, Compose companion; scrub secrets from configs
This commit is contained in:
486
services/dns-server/web/webui.go
Normal file
486
services/dns-server/web/webui.go
Normal file
@@ -0,0 +1,486 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user