138 lines
4.1 KiB
Go
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"
|
|
}
|