支持主域名同时申请多级泛域名
完善多级子域名的泛域名生成、申请类型展示、编辑回显与挑战类型校验,并补充前后端测试。
This commit is contained in:
parent
d33ee0d944
commit
79c5fe112c
@ -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 {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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({}); });
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user