Files
mailgoserver/internal/webui/utils.go
T
2026-08-12 12:56:22 +01:00

138 lines
4.1 KiB
Go

package webui
import (
"context"
"crypto/tls"
"io"
"net"
"net/http"
"strings"
"time"
"gopkg.in/ini.v1"
)
// getPublicIP mirrors server_web_ui/utils.py's get_public_ip: try ifconfig.me, then
// httpbin.org, then the configured SPF_SERVER_IP fallback, then 127.0.0.1.
func getPublicIP(cfg *ini.File) string {
client := &http.Client{
Timeout: 3 * time.Second,
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}},
}
if ip := fetchBody(client, "http://ifconfig.me/ip"); ip != "" {
return strings.TrimSpace(ip)
}
if ip := fetchBody(client, "http://httpbin.org/ip"); ip != "" {
return strings.TrimSpace(ip)
}
fallback := cfg.Section("DKIM").Key("SPF_SERVER_IP").MustString("")
if net.ParseIP(fallback) != nil {
return fallback
}
return "127.0.0.1"
}
func fetchBody(client *http.Client, url string) string {
resp, err := client.Get(url)
if err != nil {
return ""
}
defer resp.Body.Close()
b, err := io.ReadAll(io.LimitReader(resp.Body, 4096))
if err != nil {
return ""
}
return string(b)
}
// resolverAt builds a resolver pinned to a specific DNS server, mirroring
// utils.check_dns_record's hardcoded Cloudflare resolver (1.1.1.1), 5s timeout.
func resolverAt(serverIP string) *net.Resolver {
return &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
d := net.Dialer{Timeout: 5 * time.Second}
return d.DialContext(ctx, network, net.JoinHostPort(serverIP, "53"))
},
}
}
var pinnedResolver = resolverAt("1.1.1.1")
type dnsCheckResult struct {
Success bool
Message string
Records []string
}
// checkDNSRecord mirrors utils.check_dns_record for TXT lookups.
func checkDNSRecord(domain string) dnsCheckResult {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
recs, err := pinnedResolver.LookupTXT(ctx, domain)
if err != nil {
return dnsCheckResult{Success: false, Message: err.Error()}
}
if len(recs) == 0 {
return dnsCheckResult{Success: false, Message: "No TXT records found"}
}
return dnsCheckResult{Success: true, Records: recs}
}
// verificationRecordName is the DNS TXT record name a domain's ownership proof lives
// at, e.g. "_pymta-verify.example.com".
func verificationRecordName(domain string) string {
return "_pymta-verify." + domain
}
func verificationRecordValue(token string) string {
return "pymta-verify=" + token
}
// checkDomainOwnership looks up the verification TXT record via two independent public
// resolvers (1.1.1.1 and 8.8.8.8, per the user's requirement) and considers the domain
// verified if the expected token shows up via either — a domain that's genuinely been
// updated can otherwise show as unverified for a while against whichever resolver has
// a stale cache, so requiring both to agree at the same instant would be a flaky check.
func checkDomainOwnership(domain, token string) (verified bool, checkedRecords []string, err error) {
recordName := verificationRecordName(domain)
expected := verificationRecordValue(token)
var lastErr error
for _, resolverIP := range []string{"1.1.1.1", "8.8.8.8"} {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
recs, lookupErr := resolverAt(resolverIP).LookupTXT(ctx, recordName)
cancel()
if lookupErr != nil {
lastErr = lookupErr
continue
}
checkedRecords = append(checkedRecords, recs...)
for _, r := range recs {
if strings.TrimSpace(r) == expected {
return true, checkedRecords, nil
}
}
}
if len(checkedRecords) == 0 && lastErr != nil {
return false, nil, lastErr
}
return false, checkedRecords, nil
}
// generateSPFRecord mirrors utils.generate_spf_record.
func generateSPFRecord(serverIP, existingSPF string) string {
if existingSPF == "" {
return "v=spf1 ip4:" + serverIP + " ~all"
}
if strings.Contains(existingSPF, "ip4:"+serverIP) {
return existingSPF
}
for _, all := range []string{"-all", "~all", "all"} {
if idx := strings.LastIndex(existingSPF, all); idx >= 0 {
return strings.TrimSpace(existingSPF[:idx]) + " ip4:" + serverIP + " " + all
}
}
return existingSPF + " ip4:" + serverIP + " ~all"
}