From 79c5fe112c12a3434e0a6be27762e84348d10529 Mon Sep 17 00:00:00 2001 From: xuans Date: Fri, 16 Oct 2026 02:19:20 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E4=B8=BB=E5=9F=9F=E5=90=8D?= =?UTF-8?q?=E5=90=8C=E6=97=B6=E7=94=B3=E8=AF=B7=E5=A4=9A=E7=BA=A7=E6=B3=9B?= =?UTF-8?q?=E5=9F=9F=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 完善多级子域名的泛域名生成、申请类型展示、编辑回显与挑战类型校验,并补充前后端测试。 --- backend/internal/domain/domain_test.go | 10 ++++++ .../internal/httpapi/http_challenge_test.go | 32 ++++++++++++++++++ backend/internal/httpapi/server.go | 17 +++++++++- frontend/src/views/Dashboard/Dashboard.js | 11 +++++++ frontend/src/views/Dashboard/Dashboard.vue | 6 ++-- frontend/src/views/Dashboard/DomainForm.js | 33 +++++++++++++++++-- .../src/views/Dashboard/DomainForm.test.js | 25 ++++++++++++++ frontend/src/views/Dashboard/DomainModal.css | 13 ++++++++ .../src/views/Dashboard/DomainModal.test.js | 18 ++++++++++ frontend/src/views/Dashboard/DomainModal.vue | 26 ++++++++++++++- 10 files changed, 184 insertions(+), 7 deletions(-) diff --git a/backend/internal/domain/domain_test.go b/backend/internal/domain/domain_test.go index b1c8e59..0a867ed 100644 --- a/backend/internal/domain/domain_test.go +++ b/backend/internal/domain/domain_test.go @@ -59,6 +59,16 @@ func TestNewDomainSANValidation(t *testing.T) { } } +func TestNewDomainSANAllowsWildcardIdentifier(t *testing.T) { + result, err := NewDomain("x.x.x.com", CertificateSAN, []string{"*.x.x.x.com"}) + if err != nil { + t.Fatalf("多级域名与对应泛域名的 SAN 配置不应失败:%v", err) + } + if len(result.SANs) != 2 || result.SANs[0] != "x.x.x.com" || result.SANs[1] != "*.x.x.x.com" { + t.Fatalf("SAN 域名顺序或内容不正确:%v", result.SANs) + } +} + func TestNewDomainOptionsValidation(t *testing.T) { item, err := NewDomainWithOptions("example.com", CertificateSingle, nil, false, ACMEEnvironmentProduction, "cloudflare") if err != nil { diff --git a/backend/internal/httpapi/http_challenge_test.go b/backend/internal/httpapi/http_challenge_test.go index 2c73099..0d87d05 100644 --- a/backend/internal/httpapi/http_challenge_test.go +++ b/backend/internal/httpapi/http_challenge_test.go @@ -78,6 +78,38 @@ func TestHTTPDomainCreateAndUpdateBypassDNSOnly(t *testing.T) { } } +func TestDomainCreateCanIncludeWildcardForMultiLevelName(t *testing.T) { + item, err := newDomainFromRequest(domainRequest{ + Name: "x.x.x.com", + CertificateType: string(domain.CertificateSingle), + IncludeWildcard: true, + ChallengeType: stringPointer(domain.ChallengeDNS01), + }) + if err != nil { + t.Fatalf("主域名同时申请泛域名失败:%v", err) + } + if item.CertificateType != domain.CertificateSAN { + t.Fatalf("联合申请应使用 SAN 证书链路:%s", item.CertificateType) + } + if len(item.SANs) != 2 || item.SANs[0] != "x.x.x.com" || item.SANs[1] != "*.x.x.x.com" { + t.Fatalf("联合申请的多级域名标识不正确:%v", item.SANs) + } + + _, err = newDomainFromRequest(domainRequest{ + Name: "x.x.x.com", + CertificateType: string(domain.CertificateSingle), + IncludeWildcard: true, + ChallengeType: stringPointer(domain.ChallengeHTTP01), + }) + if err == nil { + t.Fatal("主域名同时申请泛域名时不应允许 HTTP-01") + } +} + +func stringPointer(value string) *string { + return &value +} + func TestHTTPTaskAuthorizationRetainsProjectPermissions(t *testing.T) { for _, denied := range []domain.ProjectPermission{domain.PermissionDNSCredentialUse, domain.PermissionCertificateManage, domain.PermissionDeploymentExecute} { t.Run(string(denied), func(t *testing.T) { diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go index ea9ac95..245faac 100644 --- a/backend/internal/httpapi/server.go +++ b/backend/internal/httpapi/server.go @@ -422,6 +422,7 @@ type domainRequest struct { Name string `json:"name" binding:"required"` CertificateType string `json:"certificate_type" binding:"required"` SANs []string `json:"sans"` + IncludeWildcard bool `json:"include_wildcard"` AutoRenew *bool `json:"auto_renew"` ACMEEnvironment string `json:"acme_environment"` DNSProvider string `json:"dns_provider"` @@ -756,7 +757,21 @@ func newDomainFromRequest(request domainRequest) (domain.Domain, error) { if acmeEnvironment == "" { acmeEnvironment = domain.ACMEEnvironmentStaging } - item, err := domain.NewDomainWithOptions(request.Name, domain.CertificateType(request.CertificateType), request.SANs, autoRenew, acmeEnvironment, request.DNSProvider) + certificateType := domain.CertificateType(request.CertificateType) + sans := append([]string(nil), request.SANs...) + if request.IncludeWildcard { + name := strings.TrimSpace(request.Name) + if strings.HasPrefix(name, "*.") { + return domain.Domain{}, errors.New("同时申请泛域名时,主域名不能以 *. 开头") + } + if certificateType != domain.CertificateSingle { + return domain.Domain{}, errors.New("同时申请泛域名仅适用于单域名证书") + } + wildcardName := "*." + strings.ToLower(name) + sans = append(sans, wildcardName) + certificateType = domain.CertificateSAN + } + item, err := domain.NewDomainWithOptions(request.Name, certificateType, sans, autoRenew, acmeEnvironment, request.DNSProvider) if err != nil { return domain.Domain{}, err } diff --git a/frontend/src/views/Dashboard/Dashboard.js b/frontend/src/views/Dashboard/Dashboard.js index 8f80e92..b7007ff 100644 --- a/frontend/src/views/Dashboard/Dashboard.js +++ b/frontend/src/views/Dashboard/Dashboard.js @@ -465,6 +465,16 @@ export function useDashboard() { return labels[value] || (value ? "未知" : "—"); } + function certificateTypeLabel(item) { + const name = (item?.name || "").trim().toLowerCase(); + const names = new Set((item?.sans || []).map((value) => value.trim().toLowerCase())); + if (item?.certificate_type === "san" && name && names.size === 2 + && names.has(name) && names.has(`*.${name}`)) { + return "主域名 + 泛域名"; + } + return displayLabel(certificateTypeLabels, item?.certificate_type); + } + function formatDate(value) { if (!value) return "尚未签发"; return new Intl.DateTimeFormat("zh-CN", { @@ -1191,6 +1201,7 @@ export function useDashboard() { serverSaving, serverFormError, certificateTypeLabels, + certificateTypeLabel, domainStatusLabels, taskStatusLabels, taskTypeLabels, diff --git a/frontend/src/views/Dashboard/Dashboard.vue b/frontend/src/views/Dashboard/Dashboard.vue index e8dc3cb..b489df2 100644 --- a/frontend/src/views/Dashboard/Dashboard.vue +++ b/frontend/src/views/Dashboard/Dashboard.vue @@ -49,7 +49,7 @@ const { serverEditingId, serverSaving, serverFormError, - certificateTypeLabels, + certificateTypeLabel, domainStatusLabels, taskStatusLabels, taskTypeLabel, @@ -261,7 +261,7 @@ const { activeSection, activeLabel, sections, selectSection } = useDashboardNavi
{{ item.name }} - {{ displayLabel(certificateTypeLabels, item.certificate_type) }} + {{ certificateTypeLabel(item) }}
证书状态 @@ -358,7 +358,7 @@ const { activeSection, activeLabel, sections, selectSection } = useDashboardNavi
{{ - displayLabel(certificateTypeLabels, item.certificate_type) + certificateTypeLabel(item) }} {{ displayLabel(environmentLabels, item.acme_environment) diff --git a/frontend/src/views/Dashboard/DomainForm.js b/frontend/src/views/Dashboard/DomainForm.js index 7d744ff..3bd21fa 100644 --- a/frontend/src/views/Dashboard/DomainForm.js +++ b/frontend/src/views/Dashboard/DomainForm.js @@ -6,6 +6,7 @@ function emptyForm() { project_id: "default", name: "", certificate_type: "single", + include_wildcard: false, sans: "", auto_renew: true, acme_environment: "staging", @@ -16,10 +17,15 @@ function emptyForm() { } function domainPayload(form) { + const name = form.name.trim(); + const includeWildcard = form.certificate_type === "single" + && Boolean(form.include_wildcard) + && !name.startsWith("*."); return { name: form.name, certificate_type: form.certificate_type, sans: form.sans.split(/[\n,,]/).map((item) => item.trim()).filter(Boolean), + include_wildcard: includeWildcard, auto_renew: form.auto_renew, acme_environment: form.acme_environment, dns_provider: form.challenge_type === "http-01" ? "" : form.dns_provider, @@ -70,12 +76,18 @@ 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 combinedWildcard = item.certificate_type === "san" + && itemSANs.length === 1 + && itemSANs[0].trim().toLowerCase() === `*.${normalizedName}`; form.value = { ...emptyForm(), project_id: item.project_id || "", name: item.name, - certificate_type: item.certificate_type, - sans: (item.sans || []).filter((name) => name !== item.name).join("\n"), + certificate_type: combinedWildcard ? "single" : item.certificate_type, + include_wildcard: combinedWildcard, + sans: combinedWildcard ? "" : itemSANs.join("\n"), auto_renew: item.auto_renew !== false, acme_environment: item.acme_environment || "staging", dns_provider: item.dns_provider || "", @@ -91,6 +103,21 @@ export function useDomainForm({ fetchAdmin, domains, servers, domainServers, not await Promise.all([reloadDomainBindings(), dnsAccount.open(item)]); } + function onCertificateTypeChange() { + if (form.value.certificate_type !== "single") { + form.value.include_wildcard = false; + } + if (form.value.include_wildcard) { + form.value.challenge_type = "dns-01"; + } + } + + function onIncludeWildcardChange() { + if (form.value.include_wildcard) { + form.value.challenge_type = "dns-01"; + } + } + function closeForm() { if (saving.value) return; version += 1; @@ -140,6 +167,7 @@ export function useDomainForm({ fetchAdmin, domains, servers, domainServers, not const payload = domainPayload(form.value); if (payload.challenge_type === "http-01" && ( payload.certificate_type === "wildcard" || + payload.include_wildcard || [payload.name, ...payload.sans].some((name) => name.trim().startsWith("*.")) )) { formError.value = "HTTP-01 不支持泛域名,请选择 DNS-01"; @@ -291,5 +319,6 @@ export function useDomainForm({ fetchAdmin, domains, servers, domainServers, not form, editingId, saving, showForm, formError, isEditing, formTitle, domainServerOptions, domainBindingsLoading, domainBindingsReady, domainSaveUncertain, 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 f6dd3c2..f3e13ba 100644 --- a/frontend/src/views/Dashboard/DomainForm.test.js +++ b/frontend/src/views/Dashboard/DomainForm.test.js @@ -53,6 +53,31 @@ test("HTTP 验证保存方式且不携带 DNS 服务商", async (t) => { assert.equal(JSON.parse(requests[0].body).dns_provider, ""); }); +test("主域名勾选同时申请泛域名时保留完整多级域名并使用 DNS-01", async (t) => { + const requests = []; + const { state } = await mountForm(t, async (path, options) => { + requests.push({ path, ...options }); + if (path.endsWith("/servers")) return Response.json({ items: [] }); + return Response.json({ + ...firstDomain, + name: "x.x.x.com", + certificate_type: "san", + sans: ["x.x.x.com", "*.x.x.x.com"], + ...JSON.parse(options.body), + }); + }); + await state.openCreate(); + state.form.value.name = "x.x.x.com"; + state.form.value.include_wildcard = true; + state.form.value.challenge_type = "dns-01"; + await state.saveDomain(); + const payload = JSON.parse(requests[0].body); + assert.equal(payload.name, "x.x.x.com"); + assert.equal(payload.certificate_type, "single"); + assert.equal(payload.include_wildcard, true); + assert.equal(payload.challenge_type, "dns-01"); +}); + test("HTTP 验证拒绝主泛域名和附加泛域名,不提交请求", async (t) => { let calls = 0; const { state } = await mountForm(t, async () => { calls += 1; return Response.json({}); }); diff --git a/frontend/src/views/Dashboard/DomainModal.css b/frontend/src/views/Dashboard/DomainModal.css index f94d2af..027c12c 100644 --- a/frontend/src/views/Dashboard/DomainModal.css +++ b/frontend/src/views/Dashboard/DomainModal.css @@ -23,6 +23,7 @@ .domain-modal .domain-sans-field, .domain-modal .form-grid, .domain-modal .switch-field, +.domain-modal .domain-wildcard-hint, .domain-modal .server-picker, .domain-modal .domain-dns-status, .domain-modal .form-error, @@ -80,6 +81,18 @@ margin: 4px 0 10px; } +.domain-modal .domain-wildcard-option small { + display: block; + margin-top: 2px; + color: #6d7f98; + font-size: 12px; + line-height: 1.45; +} + +.domain-modal .domain-wildcard-hint { + margin: -2px 0 8px; +} + .domain-modal .server-picker { min-width: 0; max-height: 130px; diff --git a/frontend/src/views/Dashboard/DomainModal.test.js b/frontend/src/views/Dashboard/DomainModal.test.js index 50432c8..043f762 100644 --- a/frontend/src/views/Dashboard/DomainModal.test.js +++ b/frontend/src/views/Dashboard/DomainModal.test.js @@ -101,6 +101,24 @@ test("域名表单不会通过背景点击误关闭,并提示泛域名格式", assert.match(html, /泛域名必须以 \*\. 开头/); }); +test("多级主域名可预览对应的一级泛域名", async () => { + const html = await renderModal({ + form: { + name: "x.x.x.com", + certificate_type: "single", + include_wildcard: true, + sans: "", + auto_renew: true, + acme_environment: "staging", + dns_provider: "", + server_ids: [], + }, + }); + assert.match(html, /同时申请泛域名/); + assert.match(html, /x\.x\.x\.com、\*\.x\.x\.x\.com/); + assert.match(html, /泛域名只能使用 DNS-01/); +}); + test("域名账户显示归属与服务商,原绑定不可用不被静默清空", async () => { const html = await renderModal({ dnsAccountID: "old-account", dnsBound: true, dnsUnavailableSelection: true, diff --git a/frontend/src/views/Dashboard/DomainModal.vue b/frontend/src/views/Dashboard/DomainModal.vue index 58535de..7fadd00 100644 --- a/frontend/src/views/Dashboard/DomainModal.vue +++ b/frontend/src/views/Dashboard/DomainModal.vue @@ -26,6 +26,8 @@ const { webServerLabels, formError, saving, + onCertificateTypeChange = () => {}, + onIncludeWildcardChange = () => {}, } = props.state; @@ -75,12 +77,34 @@ const { name="certificate_type" v-model="form.certificate_type" :disabled="saving || domainSaveUncertain" + @change="onCertificateTypeChange" > + +

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