支持主域名同时申请多级泛域名

完善多级子域名的泛域名生成、申请类型展示、编辑回显与挑战类型校验,并补充前后端测试。
This commit is contained in:
xuans 2026-10-16 02:19:20 +08:00
parent d33ee0d944
commit 79c5fe112c
10 changed files with 184 additions and 7 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -49,7 +49,7 @@ const {
serverEditingId,
serverSaving,
serverFormError,
certificateTypeLabels,
certificateTypeLabel,
domainStatusLabels,
taskStatusLabels,
taskTypeLabel,
@ -261,7 +261,7 @@ const { activeSection, activeLabel, sections, selectSection } = useDashboardNavi
<article v-for="item in paginatedDomains" :key="item.id" class="overview-domain-row">
<div class="overview-domain-main">
<strong>{{ item.name }}</strong>
<small>{{ displayLabel(certificateTypeLabels, item.certificate_type) }}</small>
<small>{{ certificateTypeLabel(item) }}</small>
</div>
<div class="overview-domain-field">
<span>证书状态</span>
@ -358,7 +358,7 @@ const { activeSection, activeLabel, sections, selectSection } = useDashboardNavi
</div>
<div class="domain-meta">
<span>{{
displayLabel(certificateTypeLabels, item.certificate_type)
certificateTypeLabel(item)
}}</span>
<span>{{
displayLabel(environmentLabels, item.acme_environment)

View File

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

View File

@ -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({}); });

View File

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

View File

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

View File

@ -26,6 +26,8 @@ const {
webServerLabels,
formError,
saving,
onCertificateTypeChange = () => {},
onIncludeWildcardChange = () => {},
} = props.state;
</script>
@ -75,12 +77,34 @@ const {
name="certificate_type"
v-model="form.certificate_type"
:disabled="saving || domainSaveUncertain"
@change="onCertificateTypeChange"
>
<option value="single">单域名</option>
<option value="wildcard">泛域名</option>
<option value="san">SAN 多域名</option>
</select></label
>
<label
v-if="form.certificate_type === 'single' && !form.name.trim().startsWith('*.')"
class="switch-field domain-wildcard-option"
for="include-wildcard"
>
<input
id="include-wildcard"
name="include_wildcard"
v-model="form.include_wildcard"
:disabled="saving || domainSaveUncertain"
type="checkbox"
@change="onIncludeWildcardChange"
/>
<span>
<strong>同时申请泛域名</strong>
<small>将按当前完整主域名同时申请 {{ form.name || "example.com" }} 和 *.{{ form.name || "example.com" }},多级子域名也只向前增加一级通配符。</small>
</span>
</label>
<p v-if="form.include_wildcard" class="field-hint domain-wildcard-hint">
本次将申请:{{ form.name }}、*.{{ form.name }};泛域名只能使用 DNS-01 验证。
</p>
<label
v-if="form.certificate_type === 'san'"
class="field domain-sans-field"
@ -117,7 +141,7 @@ const {
:disabled="saving || domainSaveUncertain || dnsBound || Boolean(dnsAccountID) || !dnsBindingReady"
>
<option value="dns-01">DNS-01(DNS 验证)</option>
<option value="http-01">HTTP-01(网站验证)</option>
<option value="http-01" :disabled="form.include_wildcard">HTTP-01(网站验证)</option>
</select>
</label>
<label v-if="form.challenge_type !== 'http-01'" class="field" for="domain-dns-account">