Files
mailgosend/internal/webadmin/server.go
T
2026-05-25 14:30:41 +00:00

305 lines
11 KiB
Go

// 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(`<span style="background:#064e3b;color:#6ee7b7;border-radius:.25rem;padding:.125rem .375rem;font-size:.7rem;font-weight:600">pass</span>`)
case "fail":
return template.HTML(`<span style="background:#7f1d1d;color:#fca5a5;border-radius:.25rem;padding:.125rem .375rem;font-size:.7rem;font-weight:600">fail</span>`)
default:
return template.HTML(`<span style="background:#1f2937;color:#9ca3af;border-radius:.25rem;padding:.125rem .375rem;font-size:.7rem">` + template.HTMLEscapeString(result) + `</span>`)
}
},
// dispositionBadge renders a coloured badge for DMARC disposition values.
"dispositionBadge": func(result string) template.HTML {
switch result {
case "none":
return template.HTML(`<span style="background:#1f2937;color:#9ca3af;border-radius:.25rem;padding:.125rem .375rem;font-size:.7rem">none</span>`)
case "quarantine":
return template.HTML(`<span style="background:#78350f;color:#fbbf24;border-radius:.25rem;padding:.125rem .375rem;font-size:.7rem;font-weight:600">quarantine</span>`)
case "reject":
return template.HTML(`<span style="background:#7f1d1d;color:#fca5a5;border-radius:.25rem;padding:.125rem .375rem;font-size:.7rem;font-weight:600">reject</span>`)
default:
return template.HTML(`<span style="background:#1f2937;color:#9ca3af;border-radius:.25rem;padding:.125rem .375rem;font-size:.7rem">` + template.HTMLEscapeString(result) + `</span>`)
}
},
}
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])
}