From 897c6cc8e835c5dcba4836f6629f31ea537c9ecd Mon Sep 17 00:00:00 2001 From: smail Date: Sun, 27 Sep 2026 01:07:08 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=88=9D=E5=A7=8B=E5=8C=96DN?= =?UTF-8?q?S=E6=9C=AA=E5=B0=B1=E7=BB=AA=E6=8F=90=E7=A4=BA=E4=B8=8E?= =?UTF-8?q?=E6=89=8B=E5=B7=A5=E5=85=AC=E7=BD=91=E5=9C=B0=E5=9D=80=E7=94=9F?= =?UTF-8?q?=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/controllers/setup.go | 6 ++- backend/services/setup/dns.go | 61 +++++++++++++++++++++--------- backend/services/setup/dns_test.go | 31 +++++++++++++-- frontend/src/views/Setup/Setup.css | 42 ++++++++++++++++++++ frontend/src/views/Setup/Setup.js | 45 +++++++++++++++++++--- frontend/src/views/Setup/Setup.vue | 37 ++++++++++++++++-- 6 files changed, 191 insertions(+), 31 deletions(-) diff --git a/backend/controllers/setup.go b/backend/controllers/setup.go index fcb1b78..4e0347c 100644 --- a/backend/controllers/setup.go +++ b/backend/controllers/setup.go @@ -196,7 +196,11 @@ func Setup(ctx *context.Context, w http.ResponseWriter, req *http.Request) { } if reqData["step"] == "dns" && reqData["action"] == "get" { - dnsInfos, err := setup.GetDNSSettings(ctx) + dnsInfos, err := setup.GetDNSSettingsWithIPv4(ctx, reqData["mail_ipv4"]) + if err == setup.ErrInvalidDNSIPv4 { + response.NewErrorResponse(response.ParamsError, err.Error(), "").FPrint(w) + return + } if err != nil { writeSetupError(ctx, w, err) return diff --git a/backend/services/setup/dns.go b/backend/services/setup/dns.go index ec5911a..6a694fe 100644 --- a/backend/services/setup/dns.go +++ b/backend/services/setup/dns.go @@ -2,6 +2,7 @@ package setup import ( stdcontext "context" + stderrors "errors" "fmt" "net" "net/netip" @@ -17,18 +18,30 @@ import ( ) type DNSItem struct { - Type string `json:"type"` - Host string `json:"host"` - Value string `json:"value"` - TTL int `json:"ttl"` - Tips string `json:"tips"` + Type string `json:"type"` + Host string `json:"host"` + Value string `json:"value"` + TTL int `json:"ttl"` + Tips string `json:"tips"` + UnavailableReason string `json:"unavailableReason,omitempty"` } const dnsRecordTTL = 600 const unresolvedWebIPv4 = "[未解析到 Web 域名公网 IPv4]" +var ErrInvalidDNSIPv4 = stderrors.New("请输入有效的邮件服务器公网 IPv4 地址,不支持内网地址、域名或端口") + func GetDNSSettings(ctx *context.Context) (map[string][]*DNSItem, error) { + return GetDNSSettingsWithIPv4(ctx, "") +} + +// 手工地址只用于本次生成 DNS 建议,不写入配置,也不替代 Web 域名解析。 +func GetDNSSettingsWithIPv4(ctx *context.Context, manualIPv4 string) (map[string][]*DNSItem, error) { + ipv4 := normalizeDNSAddress(manualIPv4) + if strings.TrimSpace(manualIPv4) != "" && ipv4 == "" { + return nil, ErrInvalidDNSIPv4 + } configData, err := config.ReadConfig() if err != nil { return nil, errors.Wrap(err) @@ -39,7 +52,9 @@ func GetDNSSettings(ctx *context.Context) (map[string][]*DNSItem, error) { return nil, errors.Wrap(err) } - ipv4 := ip.GetIPv4() + if ipv4 == "" { + ipv4 = ip.GetIPv4() + } ipv6 := ip.GetIPv6() webIPv4 := resolvePublicIPv4(configData.WebDomain) ipTips := i18n.GetText(ctx.Lang, "ip_taps") @@ -57,8 +72,9 @@ func buildDNSItems(configData *config.Config, domain, dkimPublicKey, ipv4, ipv6, func buildDNSItemsWithWebIPv4(configData *config.Config, domain, dkimPublicKey, ipv4, ipv6, ipTips string, webIPv4 []string) []*DNSItem { address := normalizeDNSAddress(ipv4) + unavailableReason := "" if address == "" { - address = "[未检测到公网 IPv4]" + unavailableReason = "未检测到公网 IPv4,请重新探测或手工填写" } items := make([]*DNSItem, 0, 14) @@ -69,11 +85,12 @@ func buildDNSItemsWithWebIPv4(configData *config.Config, domain, dkimPublicKey, } } items = append(items, &DNSItem{ - Type: "A", - Host: host, - Value: address, - TTL: dnsRecordTTL, - Tips: ipTips, + Type: "A", + Host: host, + Value: address, + TTL: dnsRecordTTL, + Tips: ipTips, + UnavailableReason: unavailableReason, }) } appendAAAARecord := func(host, address string) { @@ -106,11 +123,12 @@ func buildDNSItemsWithWebIPv4(configData *config.Config, domain, dkimPublicKey, } } else { items = append(items, &DNSItem{ - Type: "A", - Host: webHost, - Value: unresolvedWebIPv4, - TTL: dnsRecordTTL, - Tips: "Web 域名未解析到公网 IPv4,请确认域名解析状态", + Type: "A", + Host: webHost, + Value: "", + TTL: dnsRecordTTL, + Tips: "Web 域名未解析到公网 IPv4,请确认域名解析状态", + UnavailableReason: unresolvedWebIPv4, }) } appendARecord("smtp") @@ -126,9 +144,13 @@ func buildDNSItemsWithWebIPv4(configData *config.Config, domain, dkimPublicKey, } appendARecord("*") + spf := &DNSItem{Type: "TXT", Host: "@", Value: buildSPFRecord(ipv4, ipv6), TTL: dnsRecordTTL} + if spf.Value == "" { + spf.UnavailableReason = "缺少公网 IP,暂不能生成 SPF" + } items = append(items, &DNSItem{Type: "MX", Host: "@", Value: fmt.Sprintf("smtp.%s | 1", domain), TTL: dnsRecordTTL}, - &DNSItem{Type: "TXT", Host: "@", Value: buildSPFRecord(ipv4, ipv6), TTL: dnsRecordTTL}, + spf, &DNSItem{Type: "TXT", Host: "_dmarc", Value: buildDMARCRecord(domain), TTL: dnsRecordTTL}, &DNSItem{Type: "TXT", Host: "default._domainkey", Value: dkimPublicKey, TTL: dnsRecordTTL}, ) @@ -143,6 +165,9 @@ func buildSPFRecord(ipv4, ipv6 string) string { if mechanism := buildSPFAddressMechanism(ipv6); mechanism != "" { mechanisms = append(mechanisms, mechanism) } + if len(mechanisms) == 1 { + return "" + } mechanisms = append(mechanisms, "~all") return strings.Join(mechanisms, " ") } diff --git a/backend/services/setup/dns_test.go b/backend/services/setup/dns_test.go index c882d85..7212ee9 100644 --- a/backend/services/setup/dns_test.go +++ b/backend/services/setup/dns_test.go @@ -23,9 +23,9 @@ func TestBuildSPFRecordSkipsUnsafeAddresses(t *testing.T) { ipv6 string want string }{ - {name: "empty", want: "v=spf1 ~all"}, - {name: "private IPv4", ipv4: "192.168.1.20", want: "v=spf1 ~all"}, - {name: "link local IPv6", ipv6: "fe80::1", want: "v=spf1 ~all"}, + {name: "empty", want: ""}, + {name: "private IPv4", ipv4: "192.168.1.20", want: ""}, + {name: "link local IPv6", ipv6: "fe80::1", want: ""}, {name: "only IPv4", ipv4: "198.51.100.9", want: "v=spf1 ip4:198.51.100.9 ~all"}, {name: "only IPv6", ipv6: "2001:db8::9", want: "v=spf1 ip6:2001:db8::9 ~all"}, } @@ -101,7 +101,7 @@ func TestBuildDNSItemsDoesNotReuseMailIPForUnresolvedWebDomain(t *testing.T) { } items := buildDNSItemsWithWebIPv4(cfg, "example.net", "v=DKIM1; k=rsa; p=test", "198.51.100.30", "", "", nil) - if items[0].Host != "panel" || items[0].Value != unresolvedWebIPv4 { + if items[0].Host != "panel" || items[0].Value != "" || items[0].UnavailableReason != unresolvedWebIPv4 { t.Fatalf("unresolved web A record = %#v", items[0]) } if items[1].Host != "smtp" || items[1].Value != "198.51.100.30" { @@ -147,3 +147,26 @@ func TestDNSHostForDomain(t *testing.T) { }) } } + +func TestBuildDNSItemsWithoutPublicIPMarksIncompleteRecords(t *testing.T) { + cfg := &config.Config{Domain: "example.com", WebDomain: "panel.example.com"} + items := buildDNSItemsWithWebIPv4(cfg, "example.com", "v=DKIM1; k=rsa; p=test", "", "", "", []string{"203.0.113.20"}) + for _, item := range items { + unavailable := (item.Type == "A" && item.Host != "panel") || (item.Type == "TXT" && item.Host == "@") + if unavailable { + if item.Value != "" || item.UnavailableReason == "" { + t.Fatalf("未就绪记录必须留空并提供原因:%#v", item) + } + } else if item.Value == "" || item.UnavailableReason != "" { + t.Fatalf("有效记录不应被禁用:%#v", item) + } + } +} + +func TestGetDNSSettingsRejectsInvalidManualIPv4BeforeLoadingConfig(t *testing.T) { + for _, value := range []string{"127.0.0.1", "192.168.1.1", "::1", "example.com", "203.0.113.20:25", "invalid"} { + if _, err := GetDNSSettingsWithIPv4(nil, value); err != ErrInvalidDNSIPv4 { + t.Fatalf("无效手工地址 %q 应返回参数错误,实际为 %v", value, err) + } + } +} diff --git a/frontend/src/views/Setup/Setup.css b/frontend/src/views/Setup/Setup.css index 60e718c..ea089ef 100644 --- a/frontend/src/views/Setup/Setup.css +++ b/frontend/src/views/Setup/Setup.css @@ -333,6 +333,48 @@ animation: pm-rise-in 0.46s var(--pm-ease-out); } +.dns-address-controls { + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.dns-address-form { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + font-size: 13px; +} + +.dns-address-form .el-button + .el-button { + margin-left: 0; +} + +.dns-address-input { + flex: 1 1 240px; + max-width: 360px; +} + +.dns-address-note, +.dns-address-warning { + margin: 0; + font-size: 12px; + line-height: 1.5; + overflow-wrap: anywhere; +} + +.dns-address-note { + color: var(--pm-text-secondary); +} + +.dns-address-warning, +.dns-value.dns-value-unavailable { + color: var(--el-color-warning-dark-2, #b45309); + font-family: inherit; +} + .dns-header { display: flex; align-items: flex-start; diff --git a/frontend/src/views/Setup/Setup.js b/frontend/src/views/Setup/Setup.js index ece9f8a..4bf6809 100644 --- a/frontend/src/views/Setup/Setup.js +++ b/frontend/src/views/Setup/Setup.js @@ -119,6 +119,14 @@ export function usePage() { const dnsChallengeNotice = ref("") const dnsInfos = ref({}) + const dnsMailIPv4 = ref('') + const dnsAppliedIPv4 = ref('') + const dnsLoading = ref(false) + const dnsError = ref('') + const dnsInputPending = computed(() => dnsMailIPv4.value.trim() !== dnsAppliedIPv4.value) + const hasUnavailableDNS = computed(() => Object.values(dnsInfos.value).some((records) => + records.some((record) => record.unavailableReason || !record.value) + )) const hasDNSIPv6 = computed(() => Object.values(dnsInfos.value).some((records) => records.some((record) => record.type === 'AAAA') @@ -304,15 +312,34 @@ export function usePage() { } } - const getDNSConfig = () => { - return setupRequest({"action": "get", "step": "dns"}).then((res) => { + const getDNSConfig = async () => { + if (dnsLoading.value) return null + dnsLoading.value = true + dnsError.value = '' + const mailIPv4 = dnsMailIPv4.value.trim() + try { + const res = await setupRequest({action: 'get', step: 'dns', mail_ipv4: mailIPv4}) assertSetupSuccess(res) dnsInfos.value = res.data + dnsAppliedIPv4.value = mailIPv4 return res - }).catch((error) => { - showSetupError(error) + } catch (error) { + dnsInfos.value = {} + dnsError.value = showSetupError(error) return null - }) + } finally { + dnsLoading.value = false + } + } + + const detectDNSConfig = () => { + dnsMailIPv4.value = '' + return getDNSConfig() + } + + const copyDNSRecord = (record) => { + if (dnsLoading.value || dnsInputPending.value || record.unavailableReason || !record.value) return + return copyDNSValue(record.value) } const copyDNSValue = async (value) => { @@ -736,6 +763,13 @@ export function usePage() { port, proxyHTTPS, dnsInfos, + dnsMailIPv4, + dnsAppliedIPv4, + dnsLoading, + dnsError, + dnsInputPending, + hasUnavailableDNS, + detectDNSConfig, hasDNSIPv6, addDomain, setPassword, @@ -748,6 +782,7 @@ export function usePage() { clearDatabaseError, getDNSConfig, formatDNSRecordTTL, + copyDNSRecord, copyDNSValue, getSSLConfig, setSSLConfig, diff --git a/frontend/src/views/Setup/Setup.vue b/frontend/src/views/Setup/Setup.vue index 1dc0f04..9dc99a8 100644 --- a/frontend/src/views/Setup/Setup.vue +++ b/frontend/src/views/Setup/Setup.vue @@ -205,7 +205,29 @@ -
+
+
+ + + 生成记录 + 重新自动探测 +
+

仅生成解析建议,不会修改真实 DNS;后台地址独立解析,MX、SPF、DKIM、DMARC 仍需保留。

+ +

地址已修改,请生成记录后再复制。

+

部分记录尚未就绪,已禁用复制。请补充公网 IP 或检查后台域名解析;已自行配置 DNS 的用户仍可继续。

+

当前建议使用手工地址:{{ dnsAppliedIPv4 }}。请确认它是邮件服务器实际使用的公网地址。

+
+ +
@@ -253,7 +275,10 @@ >