471 lines
16 KiB
Go
471 lines
16 KiB
Go
package webui
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"mailgoserver/internal/db"
|
|
"mailgoserver/internal/dkim"
|
|
"mailgoserver/internal/dnspublish"
|
|
)
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func isAjax(r *http.Request) bool {
|
|
return r.Header.Get("X-Requested-With") == "XMLHttpRequest" || strings.Contains(r.Header.Get("Content-Type"), "application/json")
|
|
}
|
|
|
|
// dkimList mirrors dkim.py's dkim_list().
|
|
func (a *App) dkimList(w http.ResponseWriter, r *http.Request) {
|
|
active, err := a.DB.ListActiveDKIMKeysWithDomain()
|
|
if err != nil {
|
|
setFlash(w, "error", "Error loading DKIM keys")
|
|
}
|
|
inactive, _ := a.DB.ListInactiveDKIMKeysWithDomain()
|
|
publicIP := getPublicIP(a.Cfg)
|
|
scope := scopeFromContext(r)
|
|
|
|
var dkimData []M
|
|
for _, k := range active {
|
|
if !scope.Allowed(k.DomainID) {
|
|
continue
|
|
}
|
|
rec, _ := a.DKIM.GetDKIMPublicKeyRecord(k.DomainName)
|
|
if rec == nil {
|
|
rec = &dkim.DNSRecord{}
|
|
}
|
|
spfCheck := checkDNSRecord(k.DomainName)
|
|
existingSPF := ""
|
|
for _, txt := range spfCheck.Records {
|
|
if strings.Contains(txt, "v=spf1") {
|
|
existingSPF = txt
|
|
break
|
|
}
|
|
}
|
|
dkimData = append(dkimData, M{
|
|
"domain": M{"id": k.DomainID, "domain_name": k.DomainName},
|
|
"dkim_key": k.DKIMKey,
|
|
"dns_record": M{"name": rec.Name, "value": rec.Value},
|
|
"existing_spf": existingSPF,
|
|
"recommended_spf": generateSPFRecord(publicIP, existingSPF),
|
|
"public_ip": publicIP,
|
|
})
|
|
}
|
|
var oldData []M
|
|
for _, k := range inactive {
|
|
if !scope.Allowed(k.DomainID) {
|
|
continue
|
|
}
|
|
status := "Disabled"
|
|
if k.ReplacedAt != nil {
|
|
status = "Replaced"
|
|
}
|
|
oldData = append(oldData, M{
|
|
"domain": M{"id": k.DomainID, "domain_name": k.DomainName},
|
|
"dkim_key": k.DKIMKey,
|
|
"status_text": status,
|
|
})
|
|
}
|
|
|
|
warningDays := a.Cfg.Section("DKIM").Key("key_age_warning_days").MustInt(180)
|
|
|
|
globalKey, _ := a.DKIM.GetActiveGlobalDKIMKey()
|
|
globalHostname := a.Cfg.Section("DKIM").Key("global_dkim_hostname").String()
|
|
var globalRecord *dkim.DNSRecord
|
|
if globalKey != nil && globalHostname != "" {
|
|
globalRecord, _ = a.DKIM.GlobalDKIMPublicKeyRecord(globalHostname)
|
|
}
|
|
|
|
a.render(w, r, "dkim.html", M{
|
|
"active": "dkim", "dkim_data": dkimData, "old_dkim_data": oldData, "key_age_warning_days": warningDays,
|
|
"global_key": globalKey, "global_hostname": globalHostname, "global_record": globalRecord,
|
|
})
|
|
}
|
|
|
|
// createDKIM mirrors dkim.py's create_dkim().
|
|
func (a *App) createDKIM(w http.ResponseWriter, r *http.Request) {
|
|
domain := r.FormValue("domain")
|
|
selector := r.FormValue("selector")
|
|
if domain == "" {
|
|
writeJSON(w, http.StatusBadRequest, M{"success": false, "message": "Domain is required"})
|
|
return
|
|
}
|
|
dom, err := a.DB.GetDomainByNameExact(domain)
|
|
if err != nil || dom == nil {
|
|
writeJSON(w, http.StatusNotFound, M{"success": false, "message": "Domain not found"})
|
|
return
|
|
}
|
|
if !scopeFromContext(r).Allowed(dom.ID) {
|
|
writeJSON(w, http.StatusNotFound, M{"success": false, "message": "Domain not found"})
|
|
return
|
|
}
|
|
if err := a.DB.DeactivateActiveDKIMKeysForDomain(dom.ID, time.Now()); err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, M{"success": false, "message": "Failed to create DKIM key"})
|
|
return
|
|
}
|
|
ok, publishErr, err := a.DKIM.GenerateAndPublish(domain, selector, true)
|
|
if err != nil || !ok {
|
|
writeJSON(w, http.StatusInternalServerError, M{"success": false, "message": "Failed to create DKIM key"})
|
|
return
|
|
}
|
|
message := "DKIM key created successfully"
|
|
if publishErr != nil {
|
|
message = "DKIM key created, but automatic DNS publish failed: " + publishErr.Error() + " — update your DNS manually"
|
|
}
|
|
writeJSON(w, http.StatusOK, M{"success": true, "message": message})
|
|
}
|
|
|
|
// regenerateDKIM mirrors dkim.py's regenerate_dkim() — path id is a DOMAIN id.
|
|
func (a *App) regenerateDKIM(w http.ResponseWriter, r *http.Request) {
|
|
domainID := pathID(r)
|
|
dom, err := a.DB.GetDomainByID(domainID)
|
|
if err != nil || dom == nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if !requireDomainAccess(w, r, dom.ID) {
|
|
return
|
|
}
|
|
current, _ := a.DB.GetActiveDKIMKeyByDomainID(domainID)
|
|
selector := ""
|
|
if current != nil {
|
|
selector = current.Selector
|
|
}
|
|
if err := a.DB.DeactivateActiveDKIMKeysForDomain(domainID, time.Now()); err != nil {
|
|
a.dkimActionFailed(w, r, "Failed to regenerate DKIM key")
|
|
return
|
|
}
|
|
ok, publishErr, err := a.DKIM.GenerateAndPublish(dom.DomainName, selector, true)
|
|
if err != nil || !ok {
|
|
a.dkimActionFailed(w, r, "Failed to regenerate DKIM key")
|
|
return
|
|
}
|
|
message := "DKIM key regenerated successfully"
|
|
if publishErr != nil {
|
|
message = "DKIM key regenerated, but automatic DNS publish failed: " + publishErr.Error() + " — update your DNS manually"
|
|
}
|
|
|
|
if isAjax(r) {
|
|
publicIP := getPublicIP(a.Cfg)
|
|
rec, _ := a.DKIM.GetDKIMPublicKeyRecord(dom.DomainName)
|
|
spfCheck := checkDNSRecord(dom.DomainName)
|
|
existingSPF := ""
|
|
for _, txt := range spfCheck.Records {
|
|
if strings.Contains(txt, "v=spf1") {
|
|
existingSPF = txt
|
|
break
|
|
}
|
|
}
|
|
newKey, _ := a.DB.GetActiveDKIMKeyByDomainID(domainID)
|
|
writeJSON(w, http.StatusOK, M{
|
|
"success": true, "message": message,
|
|
"new_key": newKey, "dns_record": rec, "existing_spf": existingSPF,
|
|
"recommended_spf": generateSPFRecord(publicIP, existingSPF),
|
|
"public_ip": publicIP, "domain": dom.DomainName,
|
|
})
|
|
return
|
|
}
|
|
category := "success"
|
|
if publishErr != nil {
|
|
category = "error"
|
|
}
|
|
setFlash(w, category, message)
|
|
http.Redirect(w, r, Prefix+"/dkim", http.StatusFound)
|
|
}
|
|
|
|
func (a *App) dkimActionFailed(w http.ResponseWriter, r *http.Request, message string) {
|
|
if isAjax(r) {
|
|
writeJSON(w, http.StatusInternalServerError, M{"success": false, "message": message})
|
|
return
|
|
}
|
|
setFlash(w, "error", message)
|
|
http.Redirect(w, r, Prefix+"/dkim", http.StatusFound)
|
|
}
|
|
|
|
func (a *App) editDKIMForm(w http.ResponseWriter, r *http.Request) {
|
|
key, ok := a.dkimKeyWithAccess(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
dom, _ := a.DB.GetDomainByID(key.DomainID)
|
|
dnsCreds, _ := a.DB.GetDomainDNSCredentials(key.DomainID)
|
|
a.render(w, r, "edit_dkim.html", M{"active": "dkim", "dkim_key": key, "domain": dom, "dns_creds": dnsCreds})
|
|
}
|
|
|
|
var selectorPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
|
|
|
|
// dkimKeyWithAccess fetches a DKIM key by path ID and confirms it belongs to a domain
|
|
// the current admin can manage.
|
|
func (a *App) dkimKeyWithAccess(w http.ResponseWriter, r *http.Request) (key *db.DKIMKey, ok bool) {
|
|
key, err := a.DB.GetDKIMKeyByID(pathID(r))
|
|
if err != nil || key == nil {
|
|
http.NotFound(w, r)
|
|
return nil, false
|
|
}
|
|
if !requireDomainAccess(w, r, key.DomainID) {
|
|
return nil, false
|
|
}
|
|
return key, true
|
|
}
|
|
|
|
// editDKIM mirrors dkim.py's edit_dkim() POST branch.
|
|
func (a *App) editDKIM(w http.ResponseWriter, r *http.Request) {
|
|
key, ok := a.dkimKeyWithAccess(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
id := key.ID
|
|
dom, _ := a.DB.GetDomainByID(key.DomainID)
|
|
selector := strings.TrimSpace(r.FormValue("selector"))
|
|
|
|
if selector == "" || !selectorPattern.MatchString(selector) {
|
|
setFlash(w, "error", "Selector must contain only letters, numbers, hyphens, and underscores")
|
|
a.render(w, r, "edit_dkim.html", M{"active": "dkim", "dkim_key": key, "domain": dom})
|
|
return
|
|
}
|
|
if exists, _ := a.DB.SelectorExistsForDomain(key.DomainID, selector, id); exists {
|
|
setFlash(w, "error", "This selector is already in use for this domain")
|
|
a.render(w, r, "edit_dkim.html", M{"active": "dkim", "dkim_key": key, "domain": dom})
|
|
return
|
|
}
|
|
if err := a.DB.UpdateDKIMKeySelector(id, selector); err != nil {
|
|
setFlash(w, "error", "Error updating selector")
|
|
http.Redirect(w, r, Prefix+"/dkim", http.StatusFound)
|
|
return
|
|
}
|
|
setFlash(w, "success", "DKIM selector updated successfully")
|
|
http.Redirect(w, r, Prefix+"/dkim", http.StatusFound)
|
|
}
|
|
|
|
// toggleDKIM mirrors dkim.py's toggle_dkim().
|
|
func (a *App) toggleDKIM(w http.ResponseWriter, r *http.Request) {
|
|
key, ok := a.dkimKeyWithAccess(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
id := key.ID
|
|
newActive := !key.IsActive
|
|
if newActive {
|
|
if err := a.DB.DeactivateActiveDKIMKeysForDomain(key.DomainID, time.Now()); err != nil {
|
|
a.dkimActionFailed(w, r, "Error toggling DKIM status")
|
|
return
|
|
}
|
|
}
|
|
if err := a.DB.SetDKIMKeyActive(id, newActive, time.Now()); err != nil {
|
|
a.dkimActionFailed(w, r, "Error toggling DKIM status")
|
|
return
|
|
}
|
|
message := "DKIM key disabled"
|
|
if newActive {
|
|
message = "DKIM key enabled"
|
|
}
|
|
if isAjax(r) {
|
|
writeJSON(w, http.StatusOK, M{"success": true, "message": message, "is_active": newActive})
|
|
return
|
|
}
|
|
setFlash(w, "success", message)
|
|
http.Redirect(w, r, Prefix+"/dkim", http.StatusFound)
|
|
}
|
|
|
|
func (a *App) removeDKIM(w http.ResponseWriter, r *http.Request) {
|
|
key, ok := a.dkimKeyWithAccess(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := a.DB.RemoveDKIMKey(key.ID); err != nil {
|
|
setFlash(w, "error", "Error removing DKIM key")
|
|
} else {
|
|
setFlash(w, "success", "DKIM key permanently removed")
|
|
}
|
|
http.Redirect(w, r, Prefix+"/dkim", http.StatusFound)
|
|
}
|
|
|
|
// dkimDNSAutomationProviders are the DNS providers dnspublish.SetTXTRecord supports —
|
|
// shared by both the per-domain automation form here and the settings-page provider
|
|
// select (mirrors internal/acmecert's fixed [LetsEncrypt] provider set).
|
|
var dkimDNSAutomationProviders = map[string]bool{"cloudflare": true, "route53": true, "digitalocean": true, "gcloud": true}
|
|
|
|
// dkimDNSAutomationUpdate saves a domain's dkim_dns_automation mode and (if provided)
|
|
// its DNS provider credentials for GenerateAndPublish to use on the next
|
|
// create/regenerate. Routed as /dkim/{id}/dns-automation with a KEY id (not a domain
|
|
// id) — same as editDKIM/toggleDKIM above, and the page it's embedded in
|
|
// (edit_dkim.html, reached via that same key id) — unlike regenerateDKIM's
|
|
// /dkim/{id}/regenerate, which is the one DKIM route keyed by domain id instead.
|
|
func (a *App) dkimDNSAutomationUpdate(w http.ResponseWriter, r *http.Request) {
|
|
key, ok := a.dkimKeyWithAccess(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
domainID := key.DomainID
|
|
back := fmt.Sprintf("%s/dkim/%d/edit", Prefix, key.ID)
|
|
dom, err := a.DB.GetDomainByID(domainID)
|
|
if err != nil || dom == nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
mode := r.FormValue("dkim_dns_automation")
|
|
if mode != "manual" && mode != "automatic" {
|
|
setFlash(w, "error", "Invalid DNS automation mode")
|
|
http.Redirect(w, r, back, http.StatusFound)
|
|
return
|
|
}
|
|
if err := a.DB.SetDomainDKIMDNSAutomation(domainID, mode); err != nil {
|
|
setFlash(w, "error", "Error saving DNS automation setting")
|
|
http.Redirect(w, r, back, http.StatusFound)
|
|
return
|
|
}
|
|
|
|
provider := r.FormValue("dns_provider")
|
|
if provider != "" {
|
|
if !dkimDNSAutomationProviders[provider] {
|
|
setFlash(w, "error", "Unrecognized DNS provider")
|
|
http.Redirect(w, r, back, http.StatusFound)
|
|
return
|
|
}
|
|
zoneName := strings.TrimSpace(r.FormValue("zone_name"))
|
|
if zoneName == "" {
|
|
zoneName = dom.DomainName
|
|
}
|
|
creds := db.DomainDNSCredentials{
|
|
DomainID: domainID, Provider: provider, ZoneName: zoneName,
|
|
CloudflareAPIToken: r.FormValue("cloudflare_api_token"),
|
|
Route53AccessKeyID: r.FormValue("route53_access_key_id"),
|
|
Route53SecretAccessKey: r.FormValue("route53_secret_access_key"),
|
|
Route53Region: r.FormValue("route53_region"),
|
|
DigitalOceanAPIToken: r.FormValue("digitalocean_api_token"),
|
|
GCloudProject: r.FormValue("gcloud_project"),
|
|
GCloudServiceAccountJSON: r.FormValue("gcloud_service_account_json"),
|
|
}
|
|
if err := a.DB.SetDomainDNSCredentials(creds); err != nil {
|
|
setFlash(w, "error", "Error saving DNS provider credentials")
|
|
http.Redirect(w, r, back, http.StatusFound)
|
|
return
|
|
}
|
|
}
|
|
|
|
setFlash(w, "success", "DNS automation settings saved")
|
|
http.Redirect(w, r, back, http.StatusFound)
|
|
}
|
|
|
|
// globalDKIMCredsFromConfig builds dnspublish.Credentials from [DKIM]'s
|
|
// global_dkim_* settings, or nil if no provider is configured — mirrors
|
|
// internal/acmecert's buildDNSProvider, just against dnspublish's generic interface
|
|
// instead of lego's ACME-challenge-shaped one (see internal/dnspublish's doc comment
|
|
// for why those can't be reused for each other).
|
|
func (a *App) globalDKIMCredsFromConfig() *dnspublish.Credentials {
|
|
sec := a.Cfg.Section("DKIM")
|
|
provider := sec.Key("global_dkim_provider").String()
|
|
if provider == "" {
|
|
return nil
|
|
}
|
|
return &dnspublish.Credentials{
|
|
Provider: provider, ZoneName: sec.Key("global_dkim_hostname").String(),
|
|
CloudflareAPIToken: sec.Key("global_dkim_cloudflare_api_token").String(),
|
|
Route53AccessKeyID: sec.Key("global_dkim_route53_access_key_id").String(),
|
|
Route53SecretAccessKey: sec.Key("global_dkim_route53_secret_access_key").String(),
|
|
Route53Region: sec.Key("global_dkim_route53_region").String(),
|
|
DigitalOceanAPIToken: sec.Key("global_dkim_digitalocean_api_token").String(),
|
|
GCloudProject: sec.Key("global_dkim_gcloud_project").String(),
|
|
GCloudServiceAccountJSON: sec.Key("global_dkim_gcloud_service_account_json").String(),
|
|
}
|
|
}
|
|
|
|
// regenerateGlobalDKIM (re)generates the one shared/global DKIM key — global-admin
|
|
// only (wired via requireGlobalAdmin at the route), since it's server-wide, not
|
|
// attributable to any one domain, same reasoning as /backups and /monitoring. Routed
|
|
// as the parameter-free /dkim/global-key/regenerate (not /dkim/{id}/regenerate's
|
|
// {id}-shaped sibling) to avoid any ambiguity with that domain-id route.
|
|
func (a *App) regenerateGlobalDKIM(w http.ResponseWriter, r *http.Request) {
|
|
hostname := a.Cfg.Section("DKIM").Key("global_dkim_hostname").String()
|
|
publishErr, err := a.DKIM.GenerateGlobalDKIMKey(hostname, a.globalDKIMCredsFromConfig())
|
|
if err != nil {
|
|
setFlash(w, "error", "Failed to regenerate the shared DKIM key")
|
|
http.Redirect(w, r, Prefix+"/dkim", http.StatusFound)
|
|
return
|
|
}
|
|
if publishErr != nil {
|
|
setFlash(w, "error", "Shared DKIM key regenerated, but automatic DNS publish failed: "+publishErr.Error()+" — update your DNS manually")
|
|
} else {
|
|
setFlash(w, "success", "Shared DKIM key regenerated successfully")
|
|
}
|
|
http.Redirect(w, r, Prefix+"/dkim", http.StatusFound)
|
|
}
|
|
|
|
// dkimUseGlobalUpdate saves whether this domain signs with the shared/global DKIM key
|
|
// instead of its own — routed by KEY id, same as dkimDNSAutomationUpdate above.
|
|
func (a *App) dkimUseGlobalUpdate(w http.ResponseWriter, r *http.Request) {
|
|
key, ok := a.dkimKeyWithAccess(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
back := fmt.Sprintf("%s/dkim/%d/edit", Prefix, key.ID)
|
|
if err := a.DB.SetDomainUseGlobalDKIM(key.DomainID, r.FormValue("use_global_dkim") == "on"); err != nil {
|
|
setFlash(w, "error", "Error saving setting")
|
|
http.Redirect(w, r, back, http.StatusFound)
|
|
return
|
|
}
|
|
setFlash(w, "success", "Setting saved")
|
|
http.Redirect(w, r, back, http.StatusFound)
|
|
}
|
|
|
|
// checkDKIMDNS mirrors dkim.py's check_dkim_dns().
|
|
func (a *App) checkDKIMDNS(w http.ResponseWriter, r *http.Request) {
|
|
domain := r.FormValue("domain")
|
|
selector := r.FormValue("selector")
|
|
rec, err := a.DKIM.GetDKIMPublicKeyRecord(domain)
|
|
if err != nil || rec == nil {
|
|
writeJSON(w, http.StatusOK, M{"success": false, "message": "No active DKIM key for domain"})
|
|
return
|
|
}
|
|
dnsName := selector + "._domainkey." + domain
|
|
result := checkDNSRecord(dnsName)
|
|
found := false
|
|
expected := strings.Trim(rec.Value, `"`)
|
|
for _, txt := range result.Records {
|
|
if strings.Contains(txt, expected) || strings.Contains(expected, txt) {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
message := "DKIM record not found or does not match"
|
|
if found {
|
|
message = "DKIM record found and matches"
|
|
} else if !result.Success {
|
|
message = result.Message
|
|
}
|
|
writeJSON(w, http.StatusOK, M{"success": found, "message": message, "records": result.Records})
|
|
}
|
|
|
|
// checkSPFDNS mirrors dkim.py's check_spf_dns().
|
|
func (a *App) checkSPFDNS(w http.ResponseWriter, r *http.Request) {
|
|
domain := r.FormValue("domain")
|
|
result := checkDNSRecord(domain)
|
|
var spfRecord string
|
|
for _, txt := range result.Records {
|
|
if strings.Contains(txt, "v=spf1") {
|
|
spfRecord = txt
|
|
break
|
|
}
|
|
}
|
|
publicIP := getPublicIP(a.Cfg)
|
|
validForServer := spfRecord != "" && strings.Contains(spfRecord, "ip4:"+publicIP)
|
|
message := "SPF record not found"
|
|
if spfRecord != "" {
|
|
message = "SPF record found"
|
|
}
|
|
writeJSON(w, http.StatusOK, M{
|
|
"success": spfRecord != "", "message": message, "records": result.Records,
|
|
"spf_record": spfRecord, "spf_valid_for_server": validForServer,
|
|
"spf_check_message": message, "public_ip": publicIP,
|
|
})
|
|
}
|