diff --git a/backend/internal/domain/domain.go b/backend/internal/domain/domain.go index 7c7791a..5fd7c9d 100644 --- a/backend/internal/domain/domain.go +++ b/backend/internal/domain/domain.go @@ -75,10 +75,10 @@ func NewDomainWithOptions(name string, certificateType CertificateType, sans []s if certificateType != CertificateSAN { cleanSANs = []string{name} } - if certificateType == CertificateWildcard && !strings.HasPrefix(name, "*.") { + if certificateType == CertificateWildcard && !IsWildcardName(name) { return Domain{}, fmt.Errorf("泛域名必须以 *. 开头") } - if certificateType != CertificateWildcard && strings.HasPrefix(name, "*.") { + if certificateType != CertificateWildcard && IsWildcardName(name) { return Domain{}, fmt.Errorf("只有泛域名证书类型允许使用 *. 域名") } now := time.Now().UTC() @@ -104,12 +104,24 @@ func IsValidACMEEnvironment(value string) bool { func IsValidName(name string) bool { name = strings.TrimSpace(strings.ToLower(name)) - if strings.HasPrefix(name, "*.") { + if IsWildcardName(name) { name = strings.TrimPrefix(name, "*.") } return domainPattern.MatchString(name) } +func IsWildcardName(name string) bool { + return strings.HasPrefix(strings.TrimSpace(strings.ToLower(name)), "*.") +} + +func WildcardName(name string) (string, error) { + name = strings.TrimSpace(strings.ToLower(name)) + if IsWildcardName(name) || !IsValidName(name) { + return "", fmt.Errorf("主域名格式无效,无法生成泛域名:%s", name) + } + return "*." + name, nil +} + func normalizeSANs(sans []string) []string { seen := make(map[string]struct{}, len(sans)) result := make([]string, 0, len(sans)) diff --git a/backend/internal/domain/domain_test.go b/backend/internal/domain/domain_test.go index 0a867ed..c058d0a 100644 --- a/backend/internal/domain/domain_test.go +++ b/backend/internal/domain/domain_test.go @@ -81,3 +81,26 @@ func TestNewDomainOptionsValidation(t *testing.T) { t.Fatal("非法 ACME 环境未被拒绝") } } + +func TestWildcardNamePreservesMultiLevelDomain(t *testing.T) { + for _, item := range []struct { + input string + want string + }{ + {input: "x.x.com", want: "*.x.x.com"}, + {input: " X.X.X.COM ", want: "*.x.x.x.com"}, + } { + got, err := WildcardName(item.input) + if err != nil { + t.Fatalf("WildcardName(%q) failed: %v", item.input, err) + } + if got != item.want { + t.Fatalf("WildcardName(%q) = %q, want %q", item.input, got, item.want) + } + } + for _, input := range []string{"*.x.x.com", "bad_domain"} { + if _, err := WildcardName(input); err == nil { + t.Fatalf("WildcardName(%q) should reject invalid base domain", input) + } + } +} diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go index 245faac..c8d66e0 100644 --- a/backend/internal/httpapi/server.go +++ b/backend/internal/httpapi/server.go @@ -761,13 +761,16 @@ func newDomainFromRequest(request domainRequest) (domain.Domain, error) { sans := append([]string(nil), request.SANs...) if request.IncludeWildcard { name := strings.TrimSpace(request.Name) - if strings.HasPrefix(name, "*.") { + if domain.IsWildcardName(name) { return domain.Domain{}, errors.New("同时申请泛域名时,主域名不能以 *. 开头") } if certificateType != domain.CertificateSingle { return domain.Domain{}, errors.New("同时申请泛域名仅适用于单域名证书") } - wildcardName := "*." + strings.ToLower(name) + wildcardName, err := domain.WildcardName(name) + if err != nil { + return domain.Domain{}, err + } sans = append(sans, wildcardName) certificateType = domain.CertificateSAN } diff --git a/frontend/src/views/Dashboard/DomainForm.js b/frontend/src/views/Dashboard/DomainForm.js index 3bd21fa..ca1b61e 100644 --- a/frontend/src/views/Dashboard/DomainForm.js +++ b/frontend/src/views/Dashboard/DomainForm.js @@ -1,6 +1,19 @@ import { computed, onBeforeUnmount, ref } from "vue"; import { useDomainDNSAccount } from "./DomainDNSAccount.js"; +function normalizeDomainName(value) { + return String(value || "").trim().toLowerCase(); +} + +function isWildcardName(value) { + return normalizeDomainName(value).startsWith("*."); +} + +function wildcardNameFor(value) { + const name = normalizeDomainName(value); + return name && !isWildcardName(name) ? `*.${name}` : ""; +} + function emptyForm() { return { project_id: "default", @@ -17,10 +30,10 @@ function emptyForm() { } function domainPayload(form) { - const name = form.name.trim(); + const name = normalizeDomainName(form.name); const includeWildcard = form.certificate_type === "single" && Boolean(form.include_wildcard) - && !name.startsWith("*."); + && !isWildcardName(name); return { name: form.name, certificate_type: form.certificate_type, @@ -44,7 +57,8 @@ export function useDomainForm({ fetchAdmin, domains, servers, domainServers, not const domainBindingsReady = ref(true); const domainSaveUncertain = ref(false); const isEditing = computed(() => Boolean(editingId.value)); - const formTitle = computed(() => isEditing.value ? "编辑域名配置" : "添加托管域名"); + const formTitle = computed(() => isEditing.value ? '编辑域名配置' : '添加托管域名'); + const wildcardName = computed(() => wildcardNameFor(form.value.name)); const domainServerOptions = computed(() => servers.value.filter( (server) => Boolean(form.value.project_id) && server.project_id === form.value.project_id, )); @@ -76,11 +90,11 @@ export function useDomainForm({ fetchAdmin, domains, servers, domainServers, not version += 1; editingId.value = item.id; targetID = item.id; - const itemSANs = (item.sans || []).filter((name) => name !== item.name); - const normalizedName = (item.name || "").trim().toLowerCase(); + const normalizedName = normalizeDomainName(item.name); + const itemSANs = (item.sans || []).filter((name) => normalizeDomainName(name) !== normalizedName); const combinedWildcard = item.certificate_type === "san" && itemSANs.length === 1 - && itemSANs[0].trim().toLowerCase() === `*.${normalizedName}`; + && normalizeDomainName(itemSANs[0]) === `*.${normalizedName}`; form.value = { ...emptyForm(), project_id: item.project_id || "", @@ -168,7 +182,7 @@ export function useDomainForm({ fetchAdmin, domains, servers, domainServers, not if (payload.challenge_type === "http-01" && ( payload.certificate_type === "wildcard" || payload.include_wildcard || - [payload.name, ...payload.sans].some((name) => name.trim().startsWith("*.")) + [payload.name, ...payload.sans].some((name) => isWildcardName(name)) )) { formError.value = "HTTP-01 不支持泛域名,请选择 DNS-01"; return; @@ -177,11 +191,11 @@ export function useDomainForm({ fetchAdmin, domains, servers, domainServers, not formError.value = "请输入主域名"; return; } - if (payload.certificate_type === "wildcard" && !payload.name.trim().startsWith("*.")) { + if (payload.certificate_type === "wildcard" && !isWildcardName(payload.name)) { formError.value = "泛域名主域名必须以 *. 开头,例如 *.example.com"; return; } - if (payload.certificate_type !== "wildcard" && payload.name.trim().startsWith("*.")) { + if (payload.certificate_type !== "wildcard" && isWildcardName(payload.name)) { formError.value = "只有泛域名证书类型允许使用 *. 域名"; return; } @@ -317,7 +331,7 @@ export function useDomainForm({ fetchAdmin, domains, servers, domainServers, not dnsUnavailableSelection: dnsAccount.dnsUnavailableSelection, reloadDomainDNS: dnsAccount.reloadDomainDNS, form, editingId, saving, showForm, formError, isEditing, formTitle, - domainServerOptions, domainBindingsLoading, domainBindingsReady, domainSaveUncertain, + domainServerOptions, domainBindingsLoading, domainBindingsReady, domainSaveUncertain, wildcardName, openCreate, openEdit, closeForm, saveDomain, reloadDomainBindings, onCertificateTypeChange, onIncludeWildcardChange, }; diff --git a/frontend/src/views/Dashboard/DomainForm.test.js b/frontend/src/views/Dashboard/DomainForm.test.js index f3e13ba..a73612e 100644 --- a/frontend/src/views/Dashboard/DomainForm.test.js +++ b/frontend/src/views/Dashboard/DomainForm.test.js @@ -316,3 +316,29 @@ test("关联网络失败可重试,但刷新失败不能被误报为保存失 assert.equal(state.formError.value, ""); assert.match(deps.notice.value, /配置已保存.*列表刷新失败/); }); + +test("多级域名泛域名预览统一使用规范化后的完整名称", async (t) => { + const { state } = await mountForm(t, async () => Response.json({ items: [] })); + await state.openCreate(); + state.form.value.name = " X.X.X.COM "; + assert.equal(state.wildcardName.value, "*.x.x.x.com"); + state.form.value.include_wildcard = true; + assert.equal(state.wildcardName.value, "*.x.x.x.com"); +}); + +test("编辑联合申请时忽略大小写差异并正确识别多级泛域名", async (t) => { + const { state } = await mountForm(t, async () => Response.json({ items: [] })); + await state.openEdit({ + id: "combined-domain", + project_id: "default", + name: "X.X.X.COM", + certificate_type: "san", + sans: ["x.x.x.com", "*.X.X.X.COM"], + auto_renew: true, + acme_environment: "staging", + challenge_type: "dns-01", + }); + assert.equal(state.form.value.certificate_type, "single"); + assert.equal(state.form.value.include_wildcard, true); + assert.equal(state.form.value.sans, ""); +}); \ No newline at end of file diff --git a/frontend/src/views/Dashboard/DomainModal.test.js b/frontend/src/views/Dashboard/DomainModal.test.js index 043f762..6a5ac3b 100644 --- a/frontend/src/views/Dashboard/DomainModal.test.js +++ b/frontend/src/views/Dashboard/DomainModal.test.js @@ -51,6 +51,7 @@ async function renderModal(overrides = {}) { webServerLabels: {}, formError: "", saving: false, + wildcardName: "", domainBindingsLoading: false, domainBindingsReady: true, domainSaveUncertain: false, diff --git a/frontend/src/views/Dashboard/DomainModal.vue b/frontend/src/views/Dashboard/DomainModal.vue index 7fadd00..8fe1543 100644 --- a/frontend/src/views/Dashboard/DomainModal.vue +++ b/frontend/src/views/Dashboard/DomainModal.vue @@ -26,6 +26,7 @@ const { webServerLabels, formError, saving, + wildcardName = "", onCertificateTypeChange = () => {}, onIncludeWildcardChange = () => {}, } = props.state; @@ -85,7 +86,7 @@ const {

- 本次将申请:{{ form.name }}、*.{{ form.name }};泛域名只能使用 DNS-01 验证。 + 本次将申请:{{ form.name.trim().toLowerCase() }}、{{ wildcardName || ("*." + form.name.trim().toLowerCase()) }};泛域名只能使用 DNS-01 验证。