完善初始化DNS未就绪提示与手工公网地址生成
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-27 01:07:08 +08:00
parent 71f28990e1
commit 897c6cc8e8
6 changed files with 191 additions and 31 deletions

View File

@ -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

View File

@ -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, " ")
}

View File

@ -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)
}
}
}

View File

@ -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;

View File

@ -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,

View File

@ -205,7 +205,29 @@
</div>
</div>
<div v-if="Object.keys(dnsInfos).length > 0" class="dns-domain-list">
<div class="dns-address-controls">
<div class="dns-address-form">
<label for="dns-mail-ipv4">邮件服务器公网 IPv4</label>
<el-input
id="dns-mail-ipv4"
v-model="dnsMailIPv4"
class="dns-address-input"
placeholder="留空自动探测,失败时可手工填写"
:disabled="dnsLoading"
clearable
@keyup.enter="getDNSConfig()"
/>
<el-button type="primary" :loading="dnsLoading" @click="getDNSConfig()">生成记录</el-button>
<el-button :disabled="dnsLoading" @click="detectDNSConfig()">重新自动探测</el-button>
</div>
<p class="dns-address-note">仅生成解析建议,不会修改真实 DNS;后台地址独立解析,MX、SPF、DKIM、DMARC 仍需保留。</p>
<p v-if="dnsError" class="dns-address-warning" role="alert">{{ dnsError }}</p>
<p v-else-if="dnsInputPending" class="dns-address-warning" role="status">地址已修改,请生成记录后再复制。</p>
<p v-else-if="hasUnavailableDNS" class="dns-address-warning" role="status">部分记录尚未就绪,已禁用复制。请补充公网 IP 或检查后台域名解析;已自行配置 DNS 的用户仍可继续。</p>
<p v-else-if="dnsAppliedIPv4" class="dns-address-note" role="status">当前建议使用手工地址:{{ dnsAppliedIPv4 }}。请确认它是邮件服务器实际使用的公网地址。</p>
</div>
<div v-if="Object.keys(dnsInfos).length > 0" class="dns-domain-list" :aria-busy="dnsLoading">
<section class="dns-domain-card" v-for="(info, domain) in dnsInfos" :key="domain">
<div class="dns-domain-header">
<div>
@ -253,7 +275,10 @@
>
<template #default="scope">
<div class="dns-value-cell">
<el-tooltip :content="scope.row.tips" placement="top" v-if="scope.row.tips">
<span v-if="scope.row.unavailableReason || !scope.row.value" class="dns-value dns-value-unavailable">
{{ scope.row.unavailableReason || '记录尚未就绪' }}
</span>
<el-tooltip :content="scope.row.tips" placement="top" v-else-if="scope.row.tips">
<span :class="['dns-value', { 'dns-value-long': scope.row.value.length > 80 }]">
{{ scope.row.value }}
</span>
@ -261,7 +286,13 @@
<span v-else :class="['dns-value', { 'dns-value-long': scope.row.value.length > 80 }]">
{{ scope.row.value }}
</span>
<el-button class="dns-copy-button" link type="primary" @click.stop="copyDNSValue(scope.row.value)">
<el-button
class="dns-copy-button"
link
type="primary"
:disabled="dnsLoading || dnsInputPending || !!scope.row.unavailableReason || !scope.row.value"
@click.stop="copyDNSRecord(scope.row)"
>
{{ lang.dns_copy }}
</el-button>
</div>