This commit is contained in:
parent
71f28990e1
commit
897c6cc8e8
@ -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
|
||||
|
||||
@ -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, " ")
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user