// Package webadmin provides the HTTP admin panel (default: 127.0.0.1:8081). // All routes require an authenticated admin session except /admin/login. package webadmin import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "fmt" "html/template" "io/fs" "log" "net/http" "time" "ghb.freebede.com/nahakubuilder/mailgosend/internal/auth" "ghb.freebede.com/nahakubuilder/mailgosend/internal/config" appCrypto "ghb.freebede.com/nahakubuilder/mailgosend/internal/crypto" "ghb.freebede.com/nahakubuilder/mailgosend/internal/db" "ghb.freebede.com/nahakubuilder/mailgosend/internal/models" ) // Deps groups all dependencies for the admin panel. type Deps struct { DB *db.DB Crypt *appCrypto.Crypto Sessions *auth.SessionStore Brute *auth.BruteGuard Cfg *config.Config FS fs.FS // embed.FS sub-rooted at web/admin } // Server handles all admin HTTP routes. type Server struct { deps *Deps mux *http.ServeMux } // New creates an admin Server and registers all routes. func New(deps *Deps) *Server { s := &Server{deps: deps, mux: http.NewServeMux()} s.setupRoutes() return s } // Handler returns the HTTP handler for the admin server. // Requests are logged; all routes except /admin/login require admin auth. func (s *Server) Handler() http.Handler { return logMiddleware(s.mux) } // ---- routing ---- func (s *Server) setupRoutes() { m := s.mux // Root redirect — catches http://host:8081/ and http://host:8081 (no /admin/ prefix). m.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" || r.URL.Path == "" { http.Redirect(w, r, "/admin/", http.StatusSeeOther) return } http.NotFound(w, r) }) // Public m.HandleFunc("GET /admin/setup", s.setupGet) m.HandleFunc("POST /admin/setup", s.setupPost) m.HandleFunc("GET /admin/login", s.loginGet) m.HandleFunc("POST /admin/login", s.loginPost) m.HandleFunc("GET /admin/login/mfa", s.mfaGet) m.HandleFunc("POST /admin/login/mfa", s.mfaPost) m.HandleFunc("GET /admin/logout", s.logout) // Protected m.HandleFunc("GET /admin/{$}", s.require(s.dashboard)) m.HandleFunc("GET /admin/domains", s.require(s.domainsList)) m.HandleFunc("POST /admin/domains", s.require(s.domainsCreate)) m.HandleFunc("GET /admin/domains/{id}", s.require(s.domainDetail)) m.HandleFunc("GET /admin/domains/{id}/dnscheck", s.require(s.domainDNSCheck)) m.HandleFunc("GET /admin/domains/{id}/dmarc", s.require(s.domainDMARCReports)) m.HandleFunc("POST /admin/domains/{id}/dmarc/enable", s.require(s.domainEnableDMARC)) m.HandleFunc("POST /admin/domains/{id}/dmarc/disable", s.require(s.domainDisableDMARC)) m.HandleFunc("POST /admin/domains/{id}/enable", s.require(s.domainToggleEnable)) m.HandleFunc("POST /admin/domains/{id}/dkim", s.require(s.domainGenDKIM)) m.HandleFunc("POST /admin/domains/{id}/limits", s.require(s.domainSetLimits)) m.HandleFunc("POST /admin/domains/{id}/delete", s.require(s.domainDelete)) m.HandleFunc("GET /admin/users", s.require(s.usersList)) m.HandleFunc("POST /admin/users", s.require(s.usersCreate)) m.HandleFunc("GET /admin/users/{id}", s.require(s.userDetail)) m.HandleFunc("POST /admin/users/{id}/update", s.require(s.userUpdate)) m.HandleFunc("POST /admin/users/{id}/password", s.require(s.userPassword)) m.HandleFunc("POST /admin/users/{id}/delete", s.require(s.userDelete)) m.HandleFunc("POST /admin/users/{id}/relay/toggle", s.require(s.userRelayToggle)) m.HandleFunc("POST /admin/users/{id}/relay/sendas", s.require(s.userRelayAddSendAs)) m.HandleFunc("POST /admin/users/{id}/relay/{sid}/delete", s.require(s.userRelayDeleteSendAs)) m.HandleFunc("POST /admin/domains/{id}/relay/add", s.require(s.domainRelayAdd)) m.HandleFunc("POST /admin/domains/{id}/relay/{rid}/delete", s.require(s.domainRelayDelete)) m.HandleFunc("GET /admin/queue", s.require(s.queueList)) m.HandleFunc("POST /admin/queue/{id}/retry", s.require(s.queueRetry)) m.HandleFunc("POST /admin/queue/{id}/delete", s.require(s.queueDelete)) m.HandleFunc("GET /admin/bans", s.require(s.bansList)) m.HandleFunc("POST /admin/bans", s.require(s.bansAdd)) m.HandleFunc("POST /admin/bans/{ip}/remove", s.require(s.bansRemove)) m.HandleFunc("GET /admin/events", s.require(s.eventsList)) // Static assets static, _ := fs.Sub(s.deps.FS, "static") m.Handle("GET /admin/static/", http.StripPrefix("/admin/static/", http.FileServer(http.FS(static)))) } // ---- middleware ---- // require wraps a handler with admin session enforcement. func (s *Server) require(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { user := s.currentAdmin(r) if user == nil { http.Redirect(w, r, "/admin/login", http.StatusSeeOther) return } next(w, r) } } // currentAdmin returns the logged-in admin user, or nil if unauthenticated / not admin. func (s *Server) currentAdmin(r *http.Request) *models.User { _, user, err := s.deps.Sessions.Get(r) if err != nil || user == nil || !user.Admin || !user.Enabled { return nil } return user } // logMiddleware logs method + path + duration for every request. func logMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() next.ServeHTTP(w, r) log.Printf("[admin] %s %s %s", r.Method, r.URL.Path, time.Since(start)) }) } // ---- CSRF ---- // csrfToken returns an HMAC-SHA256 token valid for the current clock-hour. // Bound to sessionHash so it cannot be forged without the session secret. func (s *Server) csrfToken(sessionHash string) string { h := hmac.New(sha256.New, s.deps.Cfg.SessionSecret) h.Write([]byte(sessionHash)) h.Write([]byte(time.Now().UTC().Format("2006-01-02-15"))) return hex.EncodeToString(h.Sum(nil)) } // checkCSRF validates the CSRF token submitted via form field "_csrf". // Also accepts previous-hour token to handle hour-boundary edge cases. func (s *Server) checkCSRF(r *http.Request, sessionHash string) bool { got := r.FormValue("_csrf") if got == "" { return false } cur := s.csrfToken(sessionHash) if hmac.Equal([]byte(got), []byte(cur)) { return true } // Allow previous-hour token (grace window). prev := s.csrfTokenAt(sessionHash, time.Now().UTC().Add(-time.Hour)) return hmac.Equal([]byte(got), []byte(prev)) } func (s *Server) csrfTokenAt(sessionHash string, t time.Time) string { h := hmac.New(sha256.New, s.deps.Cfg.SessionSecret) h.Write([]byte(sessionHash)) h.Write([]byte(t.Format("2006-01-02-15"))) return hex.EncodeToString(h.Sum(nil)) } // ---- Template rendering ---- // basePage holds fields available in every template. type basePage struct { Flash string Error string CSRF string Admin *models.User } func (s *Server) newBase(r *http.Request, flash, errMsg string) basePage { sessObj, user, _ := s.deps.Sessions.Get(r) var csrf string if sessObj != nil { csrf = s.csrfToken(sessObj.TokenHash) } return basePage{Flash: flash, Error: errMsg, CSRF: csrf, Admin: user} } // render parses base.html + page.html from embed.FS and executes "base" template. func (s *Server) render(w http.ResponseWriter, page string, data any) { t, err := template.New("").Funcs(tmplFuncs).ParseFS( s.deps.FS, "templates/base.html", "templates/"+page+".html", ) if err != nil { log.Printf("[admin] template parse %s: %v", page, err) http.Error(w, "template error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "DENY") if err := t.ExecuteTemplate(w, "base", data); err != nil { log.Printf("[admin] template exec %s: %v", page, err) } } // redirect sends a 303 to a path with optional flash/error query params. func redirect(w http.ResponseWriter, r *http.Request, path, flash, errMsg string) { target := path if flash != "" { target += "?flash=" + urlEncode(flash) } else if errMsg != "" { target += "?error=" + urlEncode(errMsg) } http.Redirect(w, r, target, http.StatusSeeOther) } // flashFrom extracts flash/error from query params (used after redirect). func flashFrom(r *http.Request) (flash, errMsg string) { return r.URL.Query().Get("flash"), r.URL.Query().Get("error") } // urlEncode does basic percent-encoding for query values. func urlEncode(s string) string { return fmt.Sprintf("%s", template.URLQueryEscaper(s)) } // tmplFuncs are custom template functions available in all admin templates. var tmplFuncs = template.FuncMap{ "humanBytes": humanBytes, "shortTime": func(t time.Time) string { return t.Format("2006-01-02 15:04") }, "isZero": func(t time.Time) bool { return t.IsZero() }, "mb": func(b int64) int64 { return b / 1024 / 1024 }, "not": func(v any) bool { if v == nil { return true } switch val := v.(type) { case bool: return !val case string: return val == "" case int: return val == 0 case int64: return val == 0 default: return false } }, "unixDate": func(ts int64) string { return time.Unix(ts, 0).UTC().Format("2006-01-02") }, // passBadge renders a coloured pass/fail/none badge for DMARC auth results. "passBadge": func(result string) template.HTML { switch result { case "pass": return template.HTML(`pass`) case "fail": return template.HTML(`fail`) default: return template.HTML(`` + template.HTMLEscapeString(result) + ``) } }, // dispositionBadge renders a coloured badge for DMARC disposition values. "dispositionBadge": func(result string) template.HTML { switch result { case "none": return template.HTML(`none`) case "quarantine": return template.HTML(`quarantine`) case "reject": return template.HTML(`reject`) default: return template.HTML(`` + template.HTMLEscapeString(result) + ``) } }, } func humanBytes(b int64) string { const unit = 1024 if b < unit { return fmt.Sprintf("%d B", b) } div, exp := int64(unit), 0 for n := b / unit; n >= unit; n /= unit { div *= unit exp++ } return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp]) }