1051 lines
29 KiB
Go
1051 lines
29 KiB
Go
package webadmin
|
|
|
|
import (
|
|
"context"
|
|
cryptoRand "crypto/rand"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
appCrypto "ghb.freebede.com/nahakubuilder/mailgosend/internal/crypto"
|
|
"ghb.freebede.com/nahakubuilder/mailgosend/internal/db"
|
|
"ghb.freebede.com/nahakubuilder/mailgosend/internal/dkim"
|
|
"ghb.freebede.com/nahakubuilder/mailgosend/internal/models"
|
|
)
|
|
|
|
// ---- Dashboard ----
|
|
|
|
type dashboardData struct {
|
|
basePage
|
|
Stats *db.AdminStats
|
|
}
|
|
|
|
func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
stats, err := s.deps.DB.GetAdminStats(ctx)
|
|
if err != nil {
|
|
log.Printf("[admin] stats: %v", err)
|
|
stats = &db.AdminStats{}
|
|
}
|
|
flash, errMsg := flashFrom(r)
|
|
s.render(w, "dashboard", dashboardData{
|
|
basePage: s.newBase(r, flash, errMsg),
|
|
Stats: stats,
|
|
})
|
|
}
|
|
|
|
// ---- Domains ----
|
|
|
|
type domainsData struct {
|
|
basePage
|
|
Domains []*models.Domain
|
|
}
|
|
|
|
func (s *Server) domainsList(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
doms, err := s.deps.DB.ListDomains(ctx)
|
|
if err != nil {
|
|
log.Printf("[admin] list domains: %v", err)
|
|
}
|
|
flash, errMsg := flashFrom(r)
|
|
s.render(w, "domains", domainsData{
|
|
basePage: s.newBase(r, flash, errMsg),
|
|
Domains: doms,
|
|
})
|
|
}
|
|
|
|
func (s *Server) domainsCreate(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
if !s.validateCSRF(w, r) {
|
|
return
|
|
}
|
|
|
|
name := strings.ToLower(strings.TrimSpace(r.FormValue("name")))
|
|
selector := strings.TrimSpace(r.FormValue("selector"))
|
|
algo := r.FormValue("algo")
|
|
|
|
if !validDomain(name) {
|
|
redirect(w, r, "/admin/domains", "", "Invalid domain name.")
|
|
return
|
|
}
|
|
if selector == "" {
|
|
selector = "godkim"
|
|
}
|
|
if !validIdentifier(selector) {
|
|
redirect(w, r, "/admin/domains", "", "Invalid DKIM selector.")
|
|
return
|
|
}
|
|
if algo != "rsa2048" && algo != "ed25519" {
|
|
algo = "rsa2048"
|
|
}
|
|
|
|
domID, err := s.deps.DB.CreateDomain(ctx, name, selector, algo)
|
|
if err != nil {
|
|
log.Printf("[admin] create domain: %v", err)
|
|
redirect(w, r, "/admin/domains", "", "Failed to create domain.")
|
|
return
|
|
}
|
|
|
|
// Auto-generate DKIM key pair.
|
|
if err := s.generateDKIM(ctx, domID, algo, selector); err != nil {
|
|
log.Printf("[admin] dkim keygen: %v", err)
|
|
}
|
|
|
|
redirect(w, r, fmt.Sprintf("/admin/domains/%d", domID), "Domain created.", "")
|
|
}
|
|
|
|
type domainDetailData struct {
|
|
basePage
|
|
Domain *models.Domain
|
|
Users []*models.User
|
|
Hostname string
|
|
DMARCReportCount int
|
|
// DNS records (what to configure)
|
|
MXRecord string
|
|
DKIMRecord string
|
|
SPFHint string
|
|
DMARCHint string
|
|
AutoconfigCNAME string
|
|
AutodiscoverCNAME string
|
|
SMTPSRVRecord string
|
|
IMAPSRVRecord string
|
|
}
|
|
|
|
func (s *Server) domainDetail(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
id := pathID(r, "id")
|
|
dom, err := s.deps.DB.GetDomainByID(ctx, id)
|
|
if err != nil || dom == nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
users, _ := s.deps.DB.ListUsers(ctx, id)
|
|
flash, errMsg := flashFrom(r)
|
|
|
|
hostname := s.deps.Cfg.Hostname
|
|
if hostname == "" || hostname == "mail.example.com" {
|
|
hostname = "mail." + dom.Name
|
|
}
|
|
|
|
dkimRec := ""
|
|
if dom.DKIMPublic != "" {
|
|
pub := strings.ReplaceAll(dom.DKIMPublic, "-----BEGIN PUBLIC KEY-----", "")
|
|
pub = strings.ReplaceAll(pub, "-----END PUBLIC KEY-----", "")
|
|
pub = strings.ReplaceAll(pub, "\n", "")
|
|
pub = strings.TrimSpace(pub)
|
|
dkimRec = fmt.Sprintf(`%s._domainkey.%s IN TXT "v=DKIM1; k=%s; p=%s"`,
|
|
dom.DKIMSelector, dom.Name, dkimAlgoKey(dom.DKIMAlgo), pub)
|
|
}
|
|
|
|
// Build DMARC hint — use monitoring address when configured.
|
|
dmarcHint := ""
|
|
if dom.DMARCRua != "" {
|
|
dmarcHint = fmt.Sprintf(`_dmarc.%s IN TXT "v=DMARC1; p=quarantine; rua=mailto:%s"`, dom.Name, dom.DMARCRua)
|
|
} else {
|
|
dmarcHint = fmt.Sprintf(`_dmarc.%s IN TXT "v=DMARC1; p=quarantine; rua=mailto:postmaster@%s"`, dom.Name, dom.Name)
|
|
}
|
|
|
|
dmarcCount, _ := s.deps.DB.DMARCReportCount(ctx, id)
|
|
|
|
s.render(w, "domain", domainDetailData{
|
|
basePage: s.newBase(r, flash, errMsg),
|
|
Domain: dom,
|
|
Users: users,
|
|
Hostname: hostname,
|
|
DMARCReportCount: dmarcCount,
|
|
MXRecord: fmt.Sprintf(`%s IN MX 10 %s.`, dom.Name, hostname),
|
|
DKIMRecord: dkimRec,
|
|
SPFHint: fmt.Sprintf(`%s IN TXT "v=spf1 a mx ~all"`, dom.Name),
|
|
DMARCHint: dmarcHint,
|
|
AutoconfigCNAME: fmt.Sprintf(`autoconfig.%s IN CNAME %s.`, dom.Name, hostname),
|
|
AutodiscoverCNAME: fmt.Sprintf(`autodiscover.%s IN CNAME %s.`, dom.Name, hostname),
|
|
SMTPSRVRecord: fmt.Sprintf(`_submission._tcp.%s IN SRV 0 1 587 %s.`, dom.Name, hostname),
|
|
IMAPSRVRecord: fmt.Sprintf(`_imaps._tcp.%s IN SRV 0 1 993 %s.`, dom.Name, hostname),
|
|
})
|
|
}
|
|
|
|
// ---- DNS check ----
|
|
|
|
type dnsCheckItem struct {
|
|
Status string `json:"status"` // "ok" | "warn" | "error" | "missing"
|
|
Found string `json:"found"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
type dnsCheckResult struct {
|
|
SPF dnsCheckItem `json:"spf"`
|
|
MX dnsCheckItem `json:"mx"`
|
|
DKIM dnsCheckItem `json:"dkim"`
|
|
DMARC dnsCheckItem `json:"dmarc"`
|
|
}
|
|
|
|
func (s *Server) domainDNSCheck(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
id := pathID(r, "id")
|
|
dom, err := s.deps.DB.GetDomainByID(ctx, id)
|
|
if err != nil || dom == nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
result := dnsCheckResult{
|
|
SPF: dnsSPFCheck(dom.Name),
|
|
MX: dnsMXCheck(dom.Name),
|
|
DKIM: dnsDKIMCheck(dom.DKIMSelector, dom.Name),
|
|
DMARC: dnsDMARCCheck(dom.Name),
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(result); err != nil {
|
|
log.Printf("[admin] dnscheck encode: %v", err)
|
|
}
|
|
}
|
|
|
|
func dnsSPFCheck(domain string) dnsCheckItem {
|
|
txts, err := net.LookupTXT(domain)
|
|
if err != nil {
|
|
return dnsCheckItem{"missing", "", "DNS lookup failed: " + err.Error()}
|
|
}
|
|
for _, txt := range txts {
|
|
if strings.HasPrefix(strings.TrimSpace(txt), "v=spf1") {
|
|
return dnsCheckItem{"ok", txt, "SPF record found"}
|
|
}
|
|
}
|
|
return dnsCheckItem{"error", "", "No SPF TXT record found. Add: v=spf1 a mx ~all"}
|
|
}
|
|
|
|
func dnsMXCheck(domain string) dnsCheckItem {
|
|
mxs, err := net.LookupMX(domain)
|
|
if err != nil || len(mxs) == 0 {
|
|
return dnsCheckItem{"warn", "", "No MX record found (optional but strongly recommended)"}
|
|
}
|
|
var parts []string
|
|
for _, mx := range mxs {
|
|
parts = append(parts, fmt.Sprintf("%d %s", mx.Pref, mx.Host))
|
|
}
|
|
return dnsCheckItem{"ok", strings.Join(parts, ", "), "MX record found"}
|
|
}
|
|
|
|
func dnsDKIMCheck(selector, domain string) dnsCheckItem {
|
|
if selector == "" {
|
|
return dnsCheckItem{"warn", "", "No DKIM selector configured for this domain"}
|
|
}
|
|
name := selector + "._domainkey." + domain
|
|
txts, err := net.LookupTXT(name)
|
|
if err != nil || len(txts) == 0 {
|
|
return dnsCheckItem{"error", "", "DKIM TXT record not found at " + name}
|
|
}
|
|
rec := strings.Join(txts, "")
|
|
if strings.Contains(rec, "v=DKIM1") {
|
|
return dnsCheckItem{"ok", rec, "DKIM record found at " + name}
|
|
}
|
|
return dnsCheckItem{"warn", rec, "TXT record found but missing v=DKIM1 tag at " + name}
|
|
}
|
|
|
|
func dnsDMARCCheck(domain string) dnsCheckItem {
|
|
txts, err := net.LookupTXT("_dmarc." + domain)
|
|
if err != nil || len(txts) == 0 {
|
|
return dnsCheckItem{"warn", "", "No DMARC record found (recommended for email reputation)"}
|
|
}
|
|
for _, txt := range txts {
|
|
if strings.HasPrefix(strings.TrimSpace(txt), "v=DMARC1") {
|
|
return dnsCheckItem{"ok", txt, "DMARC record found"}
|
|
}
|
|
}
|
|
return dnsCheckItem{"warn", "", "No valid v=DMARC1 record found at _dmarc." + domain}
|
|
}
|
|
|
|
func (s *Server) domainToggleEnable(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
if !s.validateCSRF(w, r) {
|
|
return
|
|
}
|
|
|
|
id := pathID(r, "id")
|
|
enabled := r.FormValue("enabled") == "1"
|
|
if err := s.deps.DB.SetDomainEnabled(ctx, id, enabled); err != nil {
|
|
log.Printf("[admin] domain enable: %v", err)
|
|
redirect(w, r, fmt.Sprintf("/admin/domains/%d", id), "", "Update failed.")
|
|
return
|
|
}
|
|
redirect(w, r, fmt.Sprintf("/admin/domains/%d", id), "Domain updated.", "")
|
|
}
|
|
|
|
func (s *Server) domainGenDKIM(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
|
defer cancel()
|
|
|
|
if !s.validateCSRF(w, r) {
|
|
return
|
|
}
|
|
|
|
id := pathID(r, "id")
|
|
dom, err := s.deps.DB.GetDomainByID(ctx, id)
|
|
if err != nil || dom == nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
algo := r.FormValue("algo")
|
|
if algo != "rsa2048" && algo != "ed25519" {
|
|
algo = dom.DKIMAlgo
|
|
}
|
|
if algo == "" {
|
|
algo = "rsa2048"
|
|
}
|
|
|
|
if err := s.generateDKIM(ctx, id, algo, dom.DKIMSelector); err != nil {
|
|
log.Printf("[admin] dkim regen: %v", err)
|
|
redirect(w, r, fmt.Sprintf("/admin/domains/%d", id), "", "DKIM generation failed.")
|
|
return
|
|
}
|
|
redirect(w, r, fmt.Sprintf("/admin/domains/%d", id), "DKIM key regenerated. Update your DNS TXT record.", "")
|
|
}
|
|
|
|
func (s *Server) domainSetLimits(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
if !s.validateCSRF(w, r) {
|
|
return
|
|
}
|
|
|
|
id := pathID(r, "id")
|
|
maxUsers, _ := strconv.Atoi(r.FormValue("max_users"))
|
|
maxQuotaMB, _ := strconv.ParseInt(r.FormValue("max_quota_mb"), 10, 64)
|
|
if maxUsers < 0 {
|
|
maxUsers = 0
|
|
}
|
|
if maxQuotaMB < 0 {
|
|
maxQuotaMB = 0
|
|
}
|
|
|
|
if err := s.deps.DB.SetDomainLimits(ctx, id, maxUsers, maxQuotaMB*1024*1024); err != nil {
|
|
log.Printf("[admin] domain limits: %v", err)
|
|
redirect(w, r, fmt.Sprintf("/admin/domains/%d", id), "", "Update failed.")
|
|
return
|
|
}
|
|
redirect(w, r, fmt.Sprintf("/admin/domains/%d", id), "Limits updated.", "")
|
|
}
|
|
|
|
func (s *Server) domainDelete(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
if !s.validateCSRF(w, r) {
|
|
return
|
|
}
|
|
|
|
id := pathID(r, "id")
|
|
if err := s.deps.DB.DeleteDomain(ctx, id); err != nil {
|
|
log.Printf("[admin] delete domain: %v", err)
|
|
redirect(w, r, fmt.Sprintf("/admin/domains/%d", id), "", "Delete failed.")
|
|
return
|
|
}
|
|
redirect(w, r, "/admin/domains", "Domain deleted.", "")
|
|
}
|
|
|
|
// ---- Users ----
|
|
|
|
type usersData struct {
|
|
basePage
|
|
Users []*db.UserWithDomain
|
|
Domains []*models.Domain
|
|
}
|
|
|
|
func (s *Server) usersList(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
users, err := s.deps.DB.ListAllUsers(ctx)
|
|
if err != nil {
|
|
log.Printf("[admin] list users: %v", err)
|
|
}
|
|
doms, _ := s.deps.DB.ListDomains(ctx)
|
|
flash, errMsg := flashFrom(r)
|
|
s.render(w, "users", usersData{
|
|
basePage: s.newBase(r, flash, errMsg),
|
|
Users: users,
|
|
Domains: doms,
|
|
})
|
|
}
|
|
|
|
func (s *Server) usersCreate(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
if !s.validateCSRF(w, r) {
|
|
return
|
|
}
|
|
|
|
domainID, _ := strconv.ParseInt(r.FormValue("domain_id"), 10, 64)
|
|
username := strings.ToLower(strings.TrimSpace(r.FormValue("username")))
|
|
password := r.FormValue("password")
|
|
displayName := strings.TrimSpace(r.FormValue("display_name"))
|
|
quotaMB, _ := strconv.ParseInt(r.FormValue("quota_mb"), 10, 64)
|
|
domainAdmin := r.FormValue("domain_admin") == "1"
|
|
|
|
if domainID <= 0 || !validUsername(username) || len(password) < 8 || len(password) > 1024 {
|
|
redirect(w, r, "/admin/users", "", "Invalid input. Username must be alphanumeric, password min 8 chars.")
|
|
return
|
|
}
|
|
if quotaMB <= 0 {
|
|
quotaMB = 1024 // 1 GB default
|
|
}
|
|
|
|
dom, err := s.deps.DB.GetDomainByID(ctx, domainID)
|
|
if err != nil || dom == nil {
|
|
redirect(w, r, "/admin/users", "", "Domain not found.")
|
|
return
|
|
}
|
|
|
|
email := username + "@" + dom.Name
|
|
exists, err := s.deps.DB.UserExistsByEmail(ctx, email)
|
|
if err != nil || exists {
|
|
redirect(w, r, "/admin/users", "", "Email already in use.")
|
|
return
|
|
}
|
|
|
|
hash, err := appCrypto.HashPassword(password)
|
|
if err != nil {
|
|
redirect(w, r, "/admin/users", "", "Password hashing failed.")
|
|
return
|
|
}
|
|
|
|
userID, err := s.deps.DB.CreateUser(ctx, domainID, username, email, hash, displayName, quotaMB*1024*1024, domainAdmin)
|
|
if err != nil {
|
|
log.Printf("[admin] create user: %v", err)
|
|
redirect(w, r, "/admin/users", "", "Failed to create user.")
|
|
return
|
|
}
|
|
|
|
// Create default mailboxes, calendar, address book.
|
|
if err := createDefaultMailboxes(ctx, s.deps.DB, userID); err != nil {
|
|
log.Printf("[admin] default mailboxes: %v", err)
|
|
}
|
|
if _, err := s.deps.DB.EnsureDefaultCalendar(ctx, userID); err != nil {
|
|
log.Printf("[admin] default calendar: %v", err)
|
|
}
|
|
if _, err := s.deps.DB.EnsureDefaultAddressBook(ctx, userID); err != nil {
|
|
log.Printf("[admin] default address book: %v", err)
|
|
}
|
|
|
|
redirect(w, r, fmt.Sprintf("/admin/users/%d", userID), "User created.", "")
|
|
}
|
|
|
|
type userDetailData struct {
|
|
basePage
|
|
U *db.UserWithDomain
|
|
}
|
|
|
|
func (s *Server) userDetail(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
id := pathID(r, "id")
|
|
users, err := s.deps.DB.ListAllUsers(ctx)
|
|
if err != nil {
|
|
http.Error(w, "db error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
var found *db.UserWithDomain
|
|
for _, u := range users {
|
|
if u.ID == id {
|
|
found = u
|
|
break
|
|
}
|
|
}
|
|
if found == nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
flash, errMsg := flashFrom(r)
|
|
s.render(w, "user", userDetailData{
|
|
basePage: s.newBase(r, flash, errMsg),
|
|
U: found,
|
|
})
|
|
}
|
|
|
|
func (s *Server) userUpdate(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
if !s.validateCSRF(w, r) {
|
|
return
|
|
}
|
|
|
|
id := pathID(r, "id")
|
|
enabled := r.FormValue("enabled") == "1"
|
|
admin := r.FormValue("admin") == "1"
|
|
domainAdmin := r.FormValue("domain_admin") == "1"
|
|
quotaMB, _ := strconv.ParseInt(r.FormValue("quota_mb"), 10, 64)
|
|
displayName := strings.TrimSpace(r.FormValue("display_name"))
|
|
|
|
if len(displayName) > 255 {
|
|
displayName = displayName[:255]
|
|
}
|
|
if quotaMB < 0 {
|
|
quotaMB = 0
|
|
}
|
|
|
|
if err := s.deps.DB.SetUserEnabled(ctx, id, enabled); err != nil {
|
|
log.Printf("[admin] user enabled: %v", err)
|
|
}
|
|
if err := s.deps.DB.SetUserAdmin(ctx, id, admin, domainAdmin); err != nil {
|
|
log.Printf("[admin] user admin: %v", err)
|
|
}
|
|
if err := s.deps.DB.SetUserQuota(ctx, id, quotaMB*1024*1024); err != nil {
|
|
log.Printf("[admin] user quota: %v", err)
|
|
}
|
|
if err := s.deps.DB.SetUserDisplayName(ctx, id, displayName); err != nil {
|
|
log.Printf("[admin] user display: %v", err)
|
|
}
|
|
|
|
redirect(w, r, fmt.Sprintf("/admin/users/%d", id), "User updated.", "")
|
|
}
|
|
|
|
func (s *Server) userPassword(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
if !s.validateCSRF(w, r) {
|
|
return
|
|
}
|
|
|
|
id := pathID(r, "id")
|
|
password := r.FormValue("password")
|
|
if len(password) < 8 || len(password) > 1024 {
|
|
redirect(w, r, fmt.Sprintf("/admin/users/%d", id), "", "Password must be 8-1024 characters.")
|
|
return
|
|
}
|
|
|
|
hash, err := appCrypto.HashPassword(password)
|
|
if err != nil {
|
|
redirect(w, r, fmt.Sprintf("/admin/users/%d", id), "", "Password error.")
|
|
return
|
|
}
|
|
if err := s.deps.DB.SetUserPassword(ctx, id, hash); err != nil {
|
|
log.Printf("[admin] set password: %v", err)
|
|
redirect(w, r, fmt.Sprintf("/admin/users/%d", id), "", "Failed to update password.")
|
|
return
|
|
}
|
|
redirect(w, r, fmt.Sprintf("/admin/users/%d", id), "Password updated.", "")
|
|
}
|
|
|
|
func (s *Server) userDelete(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
if !s.validateCSRF(w, r) {
|
|
return
|
|
}
|
|
|
|
id := pathID(r, "id")
|
|
if err := s.deps.DB.DeleteUser(ctx, id); err != nil {
|
|
log.Printf("[admin] delete user: %v", err)
|
|
redirect(w, r, fmt.Sprintf("/admin/users/%d", id), "", "Delete failed.")
|
|
return
|
|
}
|
|
redirect(w, r, "/admin/users", "User deleted.", "")
|
|
}
|
|
|
|
// ---- Queue ----
|
|
|
|
type queueData struct {
|
|
basePage
|
|
Entries []*db.QueueEntry
|
|
}
|
|
|
|
func (s *Server) queueList(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
entries, err := s.deps.DB.ListQueueEntries(ctx)
|
|
if err != nil {
|
|
log.Printf("[admin] queue list: %v", err)
|
|
}
|
|
flash, errMsg := flashFrom(r)
|
|
s.render(w, "queue", queueData{
|
|
basePage: s.newBase(r, flash, errMsg),
|
|
Entries: entries,
|
|
})
|
|
}
|
|
|
|
func (s *Server) queueRetry(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
if !s.validateCSRF(w, r) {
|
|
return
|
|
}
|
|
|
|
id := pathID(r, "id")
|
|
if err := s.deps.DB.RetryQueueEntry(ctx, id); err != nil {
|
|
log.Printf("[admin] queue retry: %v", err)
|
|
redirect(w, r, "/admin/queue", "", "Retry failed.")
|
|
return
|
|
}
|
|
redirect(w, r, "/admin/queue", "Entry queued for immediate retry.", "")
|
|
}
|
|
|
|
func (s *Server) queueDelete(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
if !s.validateCSRF(w, r) {
|
|
return
|
|
}
|
|
|
|
id := pathID(r, "id")
|
|
if err := s.deps.DB.DeleteQueueEntry(ctx, id); err != nil {
|
|
log.Printf("[admin] queue delete: %v", err)
|
|
redirect(w, r, "/admin/queue", "", "Delete failed.")
|
|
return
|
|
}
|
|
redirect(w, r, "/admin/queue", "Queue entry deleted.", "")
|
|
}
|
|
|
|
// ---- IP Bans ----
|
|
|
|
type bansData struct {
|
|
basePage
|
|
Bans []*db.IPBan
|
|
}
|
|
|
|
func (s *Server) bansList(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
bans, err := s.deps.DB.ListIPBans(ctx)
|
|
if err != nil {
|
|
log.Printf("[admin] list bans: %v", err)
|
|
}
|
|
flash, errMsg := flashFrom(r)
|
|
s.render(w, "bans", bansData{
|
|
basePage: s.newBase(r, flash, errMsg),
|
|
Bans: bans,
|
|
})
|
|
}
|
|
|
|
func (s *Server) bansAdd(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
if !s.validateCSRF(w, r) {
|
|
return
|
|
}
|
|
|
|
ip := strings.TrimSpace(r.FormValue("ip"))
|
|
reason := strings.TrimSpace(r.FormValue("reason"))
|
|
hours, _ := strconv.Atoi(r.FormValue("hours"))
|
|
|
|
if net.ParseIP(ip) == nil {
|
|
redirect(w, r, "/admin/bans", "", "Invalid IP address.")
|
|
return
|
|
}
|
|
if len(reason) > 255 {
|
|
reason = reason[:255]
|
|
}
|
|
if hours < 0 {
|
|
hours = 0
|
|
}
|
|
|
|
if err := s.deps.DB.AddIPBan(ctx, ip, reason, hours); err != nil {
|
|
log.Printf("[admin] add ban: %v", err)
|
|
redirect(w, r, "/admin/bans", "", "Failed to add ban.")
|
|
return
|
|
}
|
|
redirect(w, r, "/admin/bans", fmt.Sprintf("IP %s banned.", ip), "")
|
|
}
|
|
|
|
func (s *Server) bansRemove(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
if !s.validateCSRF(w, r) {
|
|
return
|
|
}
|
|
|
|
ip := r.PathValue("ip")
|
|
if net.ParseIP(ip) == nil {
|
|
redirect(w, r, "/admin/bans", "", "Invalid IP.")
|
|
return
|
|
}
|
|
|
|
if err := s.deps.DB.RemoveIPBan(ctx, ip); err != nil {
|
|
log.Printf("[admin] remove ban: %v", err)
|
|
redirect(w, r, "/admin/bans", "", "Remove failed.")
|
|
return
|
|
}
|
|
redirect(w, r, "/admin/bans", fmt.Sprintf("Ban on %s removed.", ip), "")
|
|
}
|
|
|
|
// ---- Security Events ----
|
|
|
|
type eventsData struct {
|
|
basePage
|
|
Events []*db.SecurityEvent
|
|
}
|
|
|
|
func (s *Server) eventsList(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
limit := 200
|
|
if q := r.URL.Query().Get("limit"); q != "" {
|
|
if n, err := strconv.Atoi(q); err == nil && n > 0 && n <= 1000 {
|
|
limit = n
|
|
}
|
|
}
|
|
|
|
evs, err := s.deps.DB.ListSecurityEvents(ctx, limit)
|
|
if err != nil {
|
|
log.Printf("[admin] events: %v", err)
|
|
}
|
|
flash, errMsg := flashFrom(r)
|
|
s.render(w, "events", eventsData{
|
|
basePage: s.newBase(r, flash, errMsg),
|
|
Events: evs,
|
|
})
|
|
}
|
|
|
|
// ---- DMARC monitoring ----
|
|
|
|
// domainEnableDMARC generates a random DMARC monitoring address for a domain.
|
|
func (s *Server) domainEnableDMARC(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
if !s.validateCSRF(w, r) {
|
|
return
|
|
}
|
|
|
|
id := pathID(r, "id")
|
|
dom, err := s.deps.DB.GetDomainByID(ctx, id)
|
|
if err != nil || dom == nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
// Generate a random 6-byte token → 12 hex chars.
|
|
token, err := generateToken(6)
|
|
if err != nil {
|
|
log.Printf("[admin] dmarc token gen: %v", err)
|
|
redirect(w, r, fmt.Sprintf("/admin/domains/%d", id), "", "Failed to generate monitoring address.")
|
|
return
|
|
}
|
|
rua := "dmarc-" + token + "@" + dom.Name
|
|
|
|
if err := s.deps.DB.SetDomainDMARCRua(ctx, id, rua); err != nil {
|
|
log.Printf("[admin] dmarc enable: %v", err)
|
|
redirect(w, r, fmt.Sprintf("/admin/domains/%d", id), "", "Failed to save monitoring address.")
|
|
return
|
|
}
|
|
redirect(w, r, fmt.Sprintf("/admin/domains/%d", id),
|
|
"DMARC monitoring enabled. Update your DNS DMARC record with the new rua= address.", "")
|
|
}
|
|
|
|
// domainDisableDMARC clears the DMARC monitoring address for a domain.
|
|
func (s *Server) domainDisableDMARC(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
if !s.validateCSRF(w, r) {
|
|
return
|
|
}
|
|
|
|
id := pathID(r, "id")
|
|
if err := s.deps.DB.SetDomainDMARCRua(ctx, id, ""); err != nil {
|
|
log.Printf("[admin] dmarc disable: %v", err)
|
|
redirect(w, r, fmt.Sprintf("/admin/domains/%d", id), "", "Failed to disable monitoring.")
|
|
return
|
|
}
|
|
redirect(w, r, fmt.Sprintf("/admin/domains/%d", id), "DMARC monitoring disabled.", "")
|
|
}
|
|
|
|
// domainDMARCReports renders the DMARC aggregate reports page for a domain.
|
|
func (s *Server) domainDMARCReports(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
id := pathID(r, "id")
|
|
dom, err := s.deps.DB.GetDomainByID(ctx, id)
|
|
if err != nil || dom == nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
limit := 50
|
|
reports, err := s.deps.DB.ListDMARCReports(ctx, id, limit)
|
|
if err != nil {
|
|
log.Printf("[admin] dmarc reports: %v", err)
|
|
}
|
|
|
|
flash, errMsg := flashFrom(r)
|
|
s.render(w, "dmarc", struct {
|
|
basePage
|
|
Domain *models.Domain
|
|
Reports []*models.DMARCReport
|
|
}{
|
|
basePage: s.newBase(r, flash, errMsg),
|
|
Domain: dom,
|
|
Reports: reports,
|
|
})
|
|
}
|
|
|
|
// ---- internal helpers ----
|
|
|
|
// validateCSRF checks the CSRF token; on failure writes a 403 and returns false.
|
|
func (s *Server) validateCSRF(w http.ResponseWriter, r *http.Request) bool {
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
return false
|
|
}
|
|
sess, _, _ := s.deps.Sessions.Get(r)
|
|
if sess == nil {
|
|
http.Error(w, "unauthenticated", http.StatusForbidden)
|
|
return false
|
|
}
|
|
if !s.checkCSRF(r, sess.TokenHash) {
|
|
http.Error(w, "CSRF validation failed", http.StatusForbidden)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// pathID extracts a positive int64 from a URL path value. Returns 0 on error.
|
|
func pathID(r *http.Request, key string) int64 {
|
|
id, _ := strconv.ParseInt(r.PathValue(key), 10, 64)
|
|
return id
|
|
}
|
|
|
|
// generateToken returns n random bytes as a lowercase hex string.
|
|
func generateToken(n int) (string, error) {
|
|
buf := make([]byte, n)
|
|
if _, err := cryptoRand.Read(buf); err != nil {
|
|
return "", err
|
|
}
|
|
return fmt.Sprintf("%x", buf), nil
|
|
}
|
|
|
|
// validDomain accepts simple dot-separated labels (a-z0-9 and hyphens).
|
|
func validDomain(s string) bool {
|
|
if len(s) < 3 || len(s) > 253 {
|
|
return false
|
|
}
|
|
for _, label := range strings.Split(s, ".") {
|
|
if len(label) == 0 || len(label) > 63 {
|
|
return false
|
|
}
|
|
for _, c := range label {
|
|
if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// validUsername accepts lowercase alphanumeric + dots + hyphens, 1-64 chars.
|
|
func validUsername(s string) bool {
|
|
if len(s) < 1 || len(s) > 64 {
|
|
return false
|
|
}
|
|
for _, c := range s {
|
|
if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.' || c == '-' || c == '_') {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// validIdentifier accepts [a-zA-Z0-9_-], 1-63 chars.
|
|
func validIdentifier(s string) bool {
|
|
if len(s) < 1 || len(s) > 63 {
|
|
return false
|
|
}
|
|
for _, c := range s {
|
|
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// dkimAlgoKey converts our algo string to the DNS TXT k= value.
|
|
func dkimAlgoKey(algo string) string {
|
|
if algo == "ed25519" {
|
|
return "ed25519"
|
|
}
|
|
return "rsa"
|
|
}
|
|
|
|
// generateDKIM creates a new DKIM key pair and persists it encrypted.
|
|
func (s *Server) generateDKIM(ctx context.Context, domainID int64, algo, selector string) error {
|
|
privPEM, pubPEM, err := dkim.GenerateKeyPair(algo)
|
|
if err != nil {
|
|
return fmt.Errorf("keygen: %w", err)
|
|
}
|
|
|
|
privEnc, err := s.deps.Crypt.EncryptGlobal("dkim", []byte(privPEM))
|
|
if err != nil {
|
|
return fmt.Errorf("encrypt dkim key: %w", err)
|
|
}
|
|
|
|
if err := s.deps.DB.SaveDKIMKeys(ctx, domainID, privEnc, pubPEM); err != nil {
|
|
return fmt.Errorf("save dkim keys: %w", err)
|
|
}
|
|
|
|
// Update algo + selector in case they changed.
|
|
_, err = s.deps.DB.SQL().ExecContext(ctx,
|
|
"UPDATE domains SET dkim_algo=?, dkim_selector=? WHERE id=?", algo, selector, domainID)
|
|
return err
|
|
}
|
|
|
|
// createDefaultMailboxes creates INBOX, Sent, Drafts, Trash, Spam, Archive for a new user.
|
|
func createDefaultMailboxes(ctx context.Context, database *db.DB, userID int64) error {
|
|
return database.CreateDefaultMailboxes(ctx, userID)
|
|
}
|
|
|
|
// ---- First-run setup ----
|
|
|
|
type setupPage struct {
|
|
basePage
|
|
}
|
|
|
|
// setupGet renders the initial setup form (only accessible when no admin user exists).
|
|
func (s *Server) setupGet(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
has, err := s.deps.DB.HasAdminUser(ctx)
|
|
if err != nil {
|
|
http.Error(w, "database error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if has {
|
|
// Setup already done — redirect to login.
|
|
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
flash, errMsg := flashFrom(r)
|
|
s.render(w, "setup", setupPage{basePage: newBaseNoUser(flash, errMsg)})
|
|
}
|
|
|
|
// setupPost creates the first admin user + its primary domain.
|
|
// Only callable when no admin user exists; subsequent calls redirect to login.
|
|
func (s *Server) setupPost(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
has, err := s.deps.DB.HasAdminUser(ctx)
|
|
if err != nil {
|
|
http.Error(w, "database error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if has {
|
|
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
|
|
password := r.FormValue("password")
|
|
confirm := r.FormValue("confirm_password")
|
|
domain := strings.ToLower(strings.TrimSpace(r.FormValue("domain")))
|
|
|
|
// Validate.
|
|
if email == "" || len(email) > 254 || !strings.Contains(email, "@") {
|
|
redirect(w, r, "/admin/setup", "", "Invalid email address.")
|
|
return
|
|
}
|
|
if domain == "" || len(domain) > 253 || strings.Contains(domain, " ") {
|
|
redirect(w, r, "/admin/setup", "", "Invalid domain name.")
|
|
return
|
|
}
|
|
if len(password) < 8 || len(password) > 1024 {
|
|
redirect(w, r, "/admin/setup", "", "Password must be at least 8 characters.")
|
|
return
|
|
}
|
|
if password != confirm {
|
|
redirect(w, r, "/admin/setup", "", "Passwords do not match.")
|
|
return
|
|
}
|
|
|
|
hash, err := appCrypto.HashPassword(password)
|
|
if err != nil {
|
|
log.Printf("[admin] setup hash: %v", err)
|
|
redirect(w, r, "/admin/setup", "", "Internal error.")
|
|
return
|
|
}
|
|
|
|
// Create domain first (user references it).
|
|
selector := s.deps.Cfg.DKIMSelector
|
|
if selector == "" {
|
|
selector = "godkim"
|
|
}
|
|
algo := s.deps.Cfg.DKIMAlgo
|
|
if algo == "" {
|
|
algo = "rsa2048"
|
|
}
|
|
domainID, err := s.deps.DB.CreateDomain(ctx, domain, selector, algo)
|
|
if err != nil {
|
|
log.Printf("[admin] setup create domain: %v", err)
|
|
redirect(w, r, "/admin/setup", "", "Failed to create domain.")
|
|
return
|
|
}
|
|
|
|
// Auto-generate DKIM key pair for the new domain.
|
|
if err := s.generateDKIM(ctx, domainID, algo, selector); err != nil {
|
|
log.Printf("[admin] setup dkim keygen: %v", err)
|
|
// Non-fatal — admin can regenerate from domain detail page.
|
|
}
|
|
|
|
// Create admin user.
|
|
userID, err := s.deps.DB.CreateAdminUser(ctx, domainID, email, hash)
|
|
if err != nil {
|
|
log.Printf("[admin] setup create admin: %v", err)
|
|
redirect(w, r, "/admin/setup", "", "Failed to create admin user.")
|
|
return
|
|
}
|
|
|
|
// Default mailboxes + calendar + address book.
|
|
if err := createDefaultMailboxes(ctx, s.deps.DB, userID); err != nil {
|
|
log.Printf("[admin] setup mailboxes: %v", err)
|
|
}
|
|
if _, err := s.deps.DB.EnsureDefaultCalendar(ctx, userID); err != nil {
|
|
log.Printf("[admin] setup calendar: %v", err)
|
|
}
|
|
if _, err := s.deps.DB.EnsureDefaultAddressBook(ctx, userID); err != nil {
|
|
log.Printf("[admin] setup addressbook: %v", err)
|
|
}
|
|
|
|
log.Printf("[admin] setup complete: admin=%s domain=%s", email, domain)
|
|
redirect(w, r, "/admin/login", "Setup complete. Sign in with your new credentials.", "")
|
|
}
|