统一多级域名泛域名生成与预览

集中处理泛域名识别、生成、编辑回显和多级子域名预览,补充前后端测试覆盖。
This commit is contained in:
xuans 2026-10-16 02:41:16 +08:00
parent 79c5fe112c
commit 73511d6fcb
7 changed files with 98 additions and 18 deletions

View File

@ -75,10 +75,10 @@ func NewDomainWithOptions(name string, certificateType CertificateType, sans []s
if certificateType != CertificateSAN { if certificateType != CertificateSAN {
cleanSANs = []string{name} cleanSANs = []string{name}
} }
if certificateType == CertificateWildcard && !strings.HasPrefix(name, "*.") { if certificateType == CertificateWildcard && !IsWildcardName(name) {
return Domain{}, fmt.Errorf("泛域名必须以 *. 开头") return Domain{}, fmt.Errorf("泛域名必须以 *. 开头")
} }
if certificateType != CertificateWildcard && strings.HasPrefix(name, "*.") { if certificateType != CertificateWildcard && IsWildcardName(name) {
return Domain{}, fmt.Errorf("只有泛域名证书类型允许使用 *. 域名") return Domain{}, fmt.Errorf("只有泛域名证书类型允许使用 *. 域名")
} }
now := time.Now().UTC() now := time.Now().UTC()
@ -104,12 +104,24 @@ func IsValidACMEEnvironment(value string) bool {
func IsValidName(name string) bool { func IsValidName(name string) bool {
name = strings.TrimSpace(strings.ToLower(name)) name = strings.TrimSpace(strings.ToLower(name))
if strings.HasPrefix(name, "*.") { if IsWildcardName(name) {
name = strings.TrimPrefix(name, "*.") name = strings.TrimPrefix(name, "*.")
} }
return domainPattern.MatchString(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 { func normalizeSANs(sans []string) []string {
seen := make(map[string]struct{}, len(sans)) seen := make(map[string]struct{}, len(sans))
result := make([]string, 0, len(sans)) result := make([]string, 0, len(sans))

View File

@ -81,3 +81,26 @@ func TestNewDomainOptionsValidation(t *testing.T) {
t.Fatal("非法 ACME 环境未被拒绝") 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)
}
}
}

View File

@ -761,13 +761,16 @@ func newDomainFromRequest(request domainRequest) (domain.Domain, error) {
sans := append([]string(nil), request.SANs...) sans := append([]string(nil), request.SANs...)
if request.IncludeWildcard { if request.IncludeWildcard {
name := strings.TrimSpace(request.Name) name := strings.TrimSpace(request.Name)
if strings.HasPrefix(name, "*.") { if domain.IsWildcardName(name) {
return domain.Domain{}, errors.New("同时申请泛域名时,主域名不能以 *. 开头") return domain.Domain{}, errors.New("同时申请泛域名时,主域名不能以 *. 开头")
} }
if certificateType != domain.CertificateSingle { if certificateType != domain.CertificateSingle {
return domain.Domain{}, errors.New("同时申请泛域名仅适用于单域名证书") 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) sans = append(sans, wildcardName)
certificateType = domain.CertificateSAN certificateType = domain.CertificateSAN
} }

View File

@ -1,6 +1,19 @@
import { computed, onBeforeUnmount, ref } from "vue"; import { computed, onBeforeUnmount, ref } from "vue";
import { useDomainDNSAccount } from "./DomainDNSAccount.js"; 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() { function emptyForm() {
return { return {
project_id: "default", project_id: "default",
@ -17,10 +30,10 @@ function emptyForm() {
} }
function domainPayload(form) { function domainPayload(form) {
const name = form.name.trim(); const name = normalizeDomainName(form.name);
const includeWildcard = form.certificate_type === "single" const includeWildcard = form.certificate_type === "single"
&& Boolean(form.include_wildcard) && Boolean(form.include_wildcard)
&& !name.startsWith("*."); && !isWildcardName(name);
return { return {
name: form.name, name: form.name,
certificate_type: form.certificate_type, certificate_type: form.certificate_type,
@ -44,7 +57,8 @@ export function useDomainForm({ fetchAdmin, domains, servers, domainServers, not
const domainBindingsReady = ref(true); const domainBindingsReady = ref(true);
const domainSaveUncertain = ref(false); const domainSaveUncertain = ref(false);
const isEditing = computed(() => Boolean(editingId.value)); 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( const domainServerOptions = computed(() => servers.value.filter(
(server) => Boolean(form.value.project_id) && server.project_id === form.value.project_id, (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; version += 1;
editingId.value = item.id; editingId.value = item.id;
targetID = item.id; targetID = item.id;
const itemSANs = (item.sans || []).filter((name) => name !== item.name); const normalizedName = normalizeDomainName(item.name);
const normalizedName = (item.name || "").trim().toLowerCase(); const itemSANs = (item.sans || []).filter((name) => normalizeDomainName(name) !== normalizedName);
const combinedWildcard = item.certificate_type === "san" const combinedWildcard = item.certificate_type === "san"
&& itemSANs.length === 1 && itemSANs.length === 1
&& itemSANs[0].trim().toLowerCase() === `*.${normalizedName}`; && normalizeDomainName(itemSANs[0]) === `*.${normalizedName}`;
form.value = { form.value = {
...emptyForm(), ...emptyForm(),
project_id: item.project_id || "", project_id: item.project_id || "",
@ -168,7 +182,7 @@ export function useDomainForm({ fetchAdmin, domains, servers, domainServers, not
if (payload.challenge_type === "http-01" && ( if (payload.challenge_type === "http-01" && (
payload.certificate_type === "wildcard" || payload.certificate_type === "wildcard" ||
payload.include_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"; formError.value = "HTTP-01 不支持泛域名,请选择 DNS-01";
return; return;
@ -177,11 +191,11 @@ export function useDomainForm({ fetchAdmin, domains, servers, domainServers, not
formError.value = "请输入主域名"; formError.value = "请输入主域名";
return; return;
} }
if (payload.certificate_type === "wildcard" && !payload.name.trim().startsWith("*.")) { if (payload.certificate_type === "wildcard" && !isWildcardName(payload.name)) {
formError.value = "泛域名主域名必须以 *. 开头,例如 *.example.com"; formError.value = "泛域名主域名必须以 *. 开头,例如 *.example.com";
return; return;
} }
if (payload.certificate_type !== "wildcard" && payload.name.trim().startsWith("*.")) { if (payload.certificate_type !== "wildcard" && isWildcardName(payload.name)) {
formError.value = "只有泛域名证书类型允许使用 *. 域名"; formError.value = "只有泛域名证书类型允许使用 *. 域名";
return; return;
} }
@ -317,7 +331,7 @@ export function useDomainForm({ fetchAdmin, domains, servers, domainServers, not
dnsUnavailableSelection: dnsAccount.dnsUnavailableSelection, dnsUnavailableSelection: dnsAccount.dnsUnavailableSelection,
reloadDomainDNS: dnsAccount.reloadDomainDNS, reloadDomainDNS: dnsAccount.reloadDomainDNS,
form, editingId, saving, showForm, formError, isEditing, formTitle, form, editingId, saving, showForm, formError, isEditing, formTitle,
domainServerOptions, domainBindingsLoading, domainBindingsReady, domainSaveUncertain, domainServerOptions, domainBindingsLoading, domainBindingsReady, domainSaveUncertain, wildcardName,
openCreate, openEdit, closeForm, saveDomain, reloadDomainBindings, openCreate, openEdit, closeForm, saveDomain, reloadDomainBindings,
onCertificateTypeChange, onIncludeWildcardChange, onCertificateTypeChange, onIncludeWildcardChange,
}; };

View File

@ -316,3 +316,29 @@ test("关联网络失败可重试,但刷新失败不能被误报为保存失
assert.equal(state.formError.value, ""); assert.equal(state.formError.value, "");
assert.match(deps.notice.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, "");
});

View File

@ -51,6 +51,7 @@ async function renderModal(overrides = {}) {
webServerLabels: {}, webServerLabels: {},
formError: "", formError: "",
saving: false, saving: false,
wildcardName: "",
domainBindingsLoading: false, domainBindingsLoading: false,
domainBindingsReady: true, domainBindingsReady: true,
domainSaveUncertain: false, domainSaveUncertain: false,

View File

@ -26,6 +26,7 @@ const {
webServerLabels, webServerLabels,
formError, formError,
saving, saving,
wildcardName = "",
onCertificateTypeChange = () => {}, onCertificateTypeChange = () => {},
onIncludeWildcardChange = () => {}, onIncludeWildcardChange = () => {},
} = props.state; } = props.state;
@ -85,7 +86,7 @@ const {
</select></label </select></label
> >
<label <label
v-if="form.certificate_type === 'single' && !form.name.trim().startsWith('*.')" v-if="form.certificate_type === 'single' && !form.name.trim().toLowerCase().startsWith('*.')"
class="switch-field domain-wildcard-option" class="switch-field domain-wildcard-option"
for="include-wildcard" for="include-wildcard"
> >
@ -99,11 +100,11 @@ const {
/> />
<span> <span>
<strong>同时申请泛域名</strong> <strong>同时申请泛域名</strong>
<small>将按当前完整主域名同时申请 {{ form.name || "example.com" }} 和 *.{{ form.name || "example.com" }},多级子域名也只向前增加一级通配符。</small> <small>将按当前完整主域名同时申请 {{ form.name.trim().toLowerCase() || "example.com" }} 和 *.{{ form.name.trim().toLowerCase() || "example.com" }},多级子域名也只向前增加一级通配符。</small>
</span> </span>
</label> </label>
<p v-if="form.include_wildcard" class="field-hint domain-wildcard-hint"> <p v-if="form.include_wildcard" class="field-hint domain-wildcard-hint">
本次将申请:{{ form.name }}、*.{{ form.name }};泛域名只能使用 DNS-01 验证。 本次将申请:{{ form.name.trim().toLowerCase() }}、{{ wildcardName || ("*." + form.name.trim().toLowerCase()) }};泛域名只能使用 DNS-01 验证。
</p> </p>
<label <label
v-if="form.certificate_type === 'san'" v-if="form.certificate_type === 'san'"