305 lines
11 KiB
Go
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])
|
|
}
|