feat: 支持dns-persist-01持久化验证方式申请证书,优化Acme账号的存储方式

This commit is contained in:
xiaojunnuo
2026-05-24 05:42:51 +08:00
parent 8edb6f8727
commit 67b05e2d75
51 changed files with 3352 additions and 110 deletions
@@ -11,14 +11,16 @@
<td class="record-value" :title="cnameRecord.recordValue">
<fs-copyable v-model="cnameRecord.recordValue"></fs-copyable>
</td>
<td class="status center flex-center">
<fs-values-format v-model="cnameRecord.status" :dict="statusDict" />
<a-tooltip v-if="cnameRecord.error" :title="cnameRecord.error">
<fs-icon class="ml-5 color-red" icon="ion:warning-outline"></fs-icon>
</a-tooltip>
<a-tooltip v-if="cnameRecord.status === 'valid'" :title="t('certd.verifyPlan.resetStatusTooltip')">
<fs-icon class="ml-2 color-yellow text-md pointer" icon="solar:undo-left-square-bold" @click="resetStatus"></fs-icon>
</a-tooltip>
<td class="status center">
<span class="status-content">
<fs-values-format v-model="cnameRecord.status" :dict="statusDict" />
<a-tooltip v-if="cnameRecord.error" :title="cnameRecord.error">
<fs-icon class="ml-5 color-red" icon="ion:warning-outline"></fs-icon>
</a-tooltip>
<a-tooltip v-if="cnameRecord.status === 'valid'" :title="t('certd.verifyPlan.resetStatusTooltip')">
<fs-icon class="ml-2 color-yellow text-md pointer" icon="solar:undo-left-square-bold" @click="resetStatus"></fs-icon>
</a-tooltip>
</span>
</td>
<td class="center">
<template v-if="cnameRecord.status !== 'valid'">
@@ -142,5 +144,10 @@ async function resetStatus() {
.fs-copyable {
width: 100%;
}
.status-content {
display: inline-flex;
align-items: center;
justify-content: center;
}
}
</style>
@@ -2,11 +2,11 @@
<table class="cname-verify-plan">
<thead>
<tr>
<td style="width: 160px">{{ t("certd.verifyPlan.hostRecord") }}</td>
<td style="width: 100px; text-align: center">{{ t("certd.verifyPlan.recordType") }}</td>
<td style="width: 250px">{{ t("certd.verifyPlan.setCnameRecord") }}</td>
<td style="width: 120px" class="center">{{ t("certd.status") }}</td>
<td style="width: 90px" class="center">{{ t("certd.verifyPlan.operation") }}</td>
<td class="col-host">{{ t("certd.verifyPlan.hostRecord") }}</td>
<td class="col-type center">{{ t("certd.verifyPlan.recordType") }}</td>
<td class="col-value">{{ t("certd.verifyPlan.setCnameRecord") }}</td>
<td class="col-status center">{{ t("certd.status") }}</td>
<td class="col-action center">{{ t("certd.verifyPlan.operation") }}</td>
</tr>
</thead>
<template v-for="key in domains" :key="key">
@@ -49,6 +49,21 @@ function onRecordChange(domain: string, record: CnameRecord) {
.cname-verify-plan {
width: 100%;
table-layout: fixed;
.col-host {
width: 220px;
}
.col-type {
width: 100px;
}
.col-value {
width: 360px;
}
.col-status {
width: 120px;
}
.col-action {
width: 150px;
}
tbody tr td {
border-top: 1px solid #e8e8e8 !important;
}
@@ -0,0 +1,144 @@
<template>
<tbody class="dns-persist-record-info">
<tr v-if="dnsPersistRecord">
<td class="host-record" :title="dnsPersistRecord.hostRecord">
<fs-copyable v-model="dnsPersistRecord.hostRecord"></fs-copyable>
</td>
<td style="text-align: center">TXT</td>
<td class="record-value" :title="dnsPersistRecord.recordValue">
<fs-copyable v-model="dnsPersistRecord.recordValue"></fs-copyable>
</td>
<td class="status center">
<fs-values-format v-model="dnsPersistRecord.status" :dict="statusDict" />
</td>
<td class="center">
<template v-if="dnsPersistRecord.status !== 'valid'">
<a-space>
<a-button type="primary" size="small" @click="openSettingDialog">设置TXT</a-button>
<a-button type="primary" size="small" :loading="loading" @click="doVerify">校验</a-button>
</a-space>
</template>
<div v-else class="helper">请勿删除TXT记录</div>
</td>
</tr>
<tr v-else>
<td colspan="5" class="color-red">{{ errorMessage || "请先选择ACME账号授权" }}</td>
</tr>
</tbody>
</template>
<script lang="ts" setup>
import { dict } from "@fast-crud/fast-crud";
import { message } from "ant-design-vue";
import { ref, watch } from "vue";
import { GetByDomain, Verify } from "/@/views/certd/cert/dns-persist/api";
import { useDnsPersistSettingDialog } from "/@/views/certd/cert/dns-persist/use-setting-dialog";
import { DnsPersistRecord } from "./type";
defineOptions({
name: "DnsPersistRecordInfo",
});
const props = defineProps<{
domain: string;
caType?: string;
acmeAccountAccessId?: number;
commonAcmeAccountAccessId?: number;
wildcard?: boolean;
persistUntil?: number;
}>();
const emit = defineEmits<{
change: [DnsPersistRecord];
}>();
const statusDict = dict({
data: [
{ value: "pending", label: "待设置", color: "warning" },
{ value: "validating", label: "校验中", color: "blue" },
{ value: "valid", label: "有效", color: "green" },
{ value: "failed", label: "请重试", color: "red" },
],
});
const dnsPersistRecord = ref<DnsPersistRecord | null>(null);
const loading = ref(false);
const errorMessage = ref("");
const { openDnsPersistSettingDialog } = useDnsPersistSettingDialog();
function onRecordChange() {
if (dnsPersistRecord.value) {
emit("change", dnsPersistRecord.value);
} else {
emit("change", {
domain: props.domain,
status: null,
} as any);
}
}
async function loadRecord() {
errorMessage.value = "";
dnsPersistRecord.value = null;
if (!props.domain || (!props.acmeAccountAccessId && !props.commonAcmeAccountAccessId)) {
onRecordChange();
return;
}
try {
dnsPersistRecord.value = await GetByDomain({
domain: props.domain,
caType: props.caType,
acmeAccountAccessId: props.acmeAccountAccessId,
commonAcmeAccountAccessId: props.commonAcmeAccountAccessId,
wildcard: props.wildcard,
persistUntil: props.persistUntil,
createOnNotFound: true,
});
onRecordChange();
} catch (e: any) {
errorMessage.value = e.message;
}
}
watch(
() => [props.domain, props.caType, props.acmeAccountAccessId, props.commonAcmeAccountAccessId, props.wildcard, props.persistUntil],
async () => {
await loadRecord();
},
{
immediate: true,
}
);
async function doVerify() {
if (!dnsPersistRecord.value?.id) {
return;
}
loading.value = true;
try {
const ok = await Verify(dnsPersistRecord.value.id);
message[ok ? "success" : "error"](ok ? "校验成功" : "未找到匹配的TXT记录,请稍后重试");
await loadRecord();
} finally {
loading.value = false;
}
}
function openSettingDialog() {
if (!dnsPersistRecord.value) {
return;
}
openDnsPersistSettingDialog({
record: dnsPersistRecord.value,
onDone: loadRecord,
});
}
</script>
<style lang="less">
.dns-persist-record-info {
.fs-copyable {
width: 100%;
}
}
</style>
@@ -0,0 +1,94 @@
<template>
<table class="dns-persist-verify-plan">
<thead>
<tr>
<td class="col-host">TXT主机名</td>
<td class="col-type center">记录类型</td>
<td class="col-value">请设置TXT记录验证成功以后不要删除</td>
<td class="col-status center">状态</td>
<td class="col-action center">操作</td>
</tr>
</thead>
<template v-for="key in domains" :key="key">
<dns-persist-record-info
:domain="key"
:ca-type="caType"
:acme-account-access-id="acmeAccountAccessId"
:common-acme-account-access-id="commonAcmeAccountAccessId"
:wildcard="modelValue[key]?.wildcard"
:persist-until="modelValue[key]?.persistUntil"
@change="onRecordChange(key, $event)"
/>
</template>
</table>
</template>
<script lang="ts" setup>
import { computed } from "vue";
import DnsPersistRecordInfo from "./dns-persist-record-info.vue";
import { DnsPersistRecord } from "./type";
defineOptions({
name: "DnsPersistVerifyPlan",
});
const emit = defineEmits(["update:modelValue", "change"]);
const props = defineProps<{
modelValue: Record<string, DnsPersistRecord>;
caType?: string;
acmeAccountAccessId?: number;
commonAcmeAccountAccessId?: number;
}>();
const domains = computed(() => {
return Object.keys(props.modelValue || {});
});
function onRecordChange(domain: string, record: DnsPersistRecord) {
const value = { ...props.modelValue };
value[domain] = {
...value[domain],
...record,
};
emit("update:modelValue", value);
emit("change", value);
}
</script>
<style lang="less">
.dns-persist-verify-plan {
width: 100%;
table-layout: fixed;
.col-host {
width: 220px;
}
.col-type {
width: 100px;
}
.col-value {
width: 360px;
}
.col-status {
width: 120px;
}
.col-action {
width: 150px;
}
tbody tr td {
border-top: 1px solid #e8e8e8 !important;
}
tr {
td {
border: 0 !important;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&.center {
text-align: center;
}
}
}
}
</style>
@@ -46,13 +46,28 @@
<div class="form-item">
<span class="label">{{ t("certd.verifyPlan.dnsAccess") }}:</span>
<span class="input">
<access-selector v-model="item.dnsProviderAccessId" size="small" :type="item.dnsProviderAccessType || item.dnsProviderType" :placeholder="t('certd.verifyPlan.pleaseSelect')" @change="onPlanChanged"></access-selector>
<access-selector
v-model="item.dnsProviderAccessId"
size="small"
:type="item.dnsProviderAccessType || item.dnsProviderType"
:placeholder="t('certd.verifyPlan.pleaseSelect')"
@change="onPlanChanged"
></access-selector>
</span>
</div>
</div>
<div v-if="item.type === 'cname'" class="plan-cname">
<cname-verify-plan v-model="item.cnameVerifyPlan" @change="onPlanChanged" />
</div>
<div v-if="item.type === 'dns-persist'" class="plan-dns-persist">
<dns-persist-verify-plan
v-model="item.dnsPersistVerifyPlan"
:ca-type="caType"
:acme-account-access-id="acmeAccountAccessId"
:common-acme-account-access-id="commonAcmeAccountAccessId"
@change="onPlanChanged"
/>
</div>
<div v-if="item.type === 'http'" class="plan-http">
<http-verify-plan v-model="item.httpVerifyPlan" @change="onPlanChanged" />
<div class="helper">{{ t("certd.verifyPlan.httpHelper") }}</div>
@@ -76,6 +91,7 @@ import { useI18n } from "vue-i18n";
import { dict, FsDictSelect } from "@fast-crud/fast-crud";
import AccessSelector from "/@/views/certd/access/access-selector/index.vue";
import CnameVerifyPlan from "./cname-verify-plan.vue";
import DnsPersistVerifyPlan from "./dns-persist-verify-plan.vue";
import HttpVerifyPlan from "./http-verify-plan.vue";
import { Form } from "ant-design-vue";
import { DomainsVerifyPlanInput } from "./type";
@@ -92,6 +108,10 @@ const challengeTypeOptions = ref<any[]>([
label: t("certd.verifyPlan.dnsChallenge"),
value: "dns",
},
{
label: "DNS持久验证",
value: "dns-persist",
},
{
label: t("certd.verifyPlan.cnameChallenge"),
value: "cname",
@@ -106,6 +126,9 @@ const props = defineProps<{
modelValue?: DomainsVerifyPlanInput;
domains?: string[];
defaultType?: string;
caType?: string;
acmeAccountAccessId?: number;
commonAcmeAccountAccessId?: number;
}>();
const emit = defineEmits<{
@@ -189,11 +212,15 @@ async function onDomainsChanged(domains: string[]) {
const cnameOrigin = planItem.cnameVerifyPlan;
const httpOrigin = planItem.httpVerifyPlan;
const dnsPersistOrigin = planItem.dnsPersistVerifyPlan;
planItem.cnameVerifyPlan = {};
planItem.httpVerifyPlan = {};
planItem.dnsPersistVerifyPlan = {};
const cnamePlan = planItem.cnameVerifyPlan;
const httpPlan = planItem.httpVerifyPlan;
const dnsPersistPlan = planItem.dnsPersistVerifyPlan;
for (const subDomain of domainGroupItem.keySubDomains) {
const wildcard = true;
if (!cnameOrigin[subDomain]) {
//@ts-ignore
planItem.cnameVerifyPlan[subDomain] = {
@@ -225,6 +252,19 @@ async function onDomainsChanged(domains: string[]) {
domain: subDomain,
};
}
if (!dnsPersistOrigin?.[subDomain]) {
//@ts-ignore
dnsPersistPlan[subDomain] = {
domain: subDomain,
wildcard,
};
} else {
dnsPersistPlan[subDomain] = {
...dnsPersistOrigin[subDomain],
wildcard,
};
}
}
for (const subDomain of Object.keys(cnamePlan)) {
@@ -238,6 +278,12 @@ async function onDomainsChanged(domains: string[]) {
delete httpPlan[subDomain];
}
}
for (const subDomain of Object.keys(dnsPersistPlan)) {
if (!domainGroupItem.keySubDomains.includes(subDomain)) {
delete dnsPersistPlan[subDomain];
}
}
}
for (const domain of Object.keys(planRef.value)) {
const mainDomains = Object.keys(domainGroups);
@@ -268,6 +314,7 @@ watch(
overflow-x: auto;
.fullscreen-modal {
display: none;
background-color: rgba(0, 0, 0, 0.42);
}
&.fullscreen {
@@ -7,15 +7,32 @@ export type HttpRecord = {
httpUploadRootDir: string;
};
export type DnsPersistRecord = {
id?: number;
domain: string;
mainDomain?: string;
status?: string;
hostRecord?: string;
recordValue?: string;
caType?: string;
acmeAccountAccessId?: number;
accountUri?: string;
wildcard?: boolean;
persistUntil?: number;
dnsProviderType?: string;
dnsProviderAccess?: number;
};
export type DomainVerifyPlanInput = {
domain: string;
domains: string[];
type: "cname" | "dns" | "http";
type: "cname" | "dns" | "http" | "dns-persist";
dnsProviderType?: string;
dnsProviderAccessType?: string;
dnsProviderAccessId?: number;
cnameVerifyPlan?: Record<string, CnameRecord>;
httpVerifyPlan?: Record<string, HttpRecord>;
dnsPersistVerifyPlan?: Record<string, DnsPersistRecord>;
};
export type DomainsVerifyPlanInput = {
[key: string]: DomainVerifyPlanInput;
@@ -46,6 +46,14 @@ function checkDomainVerifyPlan(rule: any, value: DomainsVerifyPlanInput) {
if (!value[domain].dnsProviderType || !value[domain].dnsProviderAccessId) {
throw new Error($t("certd.verifyPlan.errors.dnsProviderRequired", { domain }));
}
} else if (type === "dns-persist") {
const subDomains = Object.keys(value[domain].dnsPersistVerifyPlan || {});
for (const subDomain of subDomains) {
const plan = value[domain].dnsPersistVerifyPlan[subDomain];
if (plan.status !== "valid") {
throw new Error(`DNS持久验证记录(${subDomain})还未校验成功`);
}
}
}
}
return true;
@@ -1,8 +1,8 @@
<template>
<div class="refresh-input">
<div class="refresh-input-line">
<a-input class="refresh-input-control" :value="value" :placeholder="placeholder" allow-clear @update:value="emit('update:value', $event)"></a-input>
<fs-button :loading="loading" type="primary" :text="buttonText" :icon="icon" @click="doRefresh"></fs-button>
<a-input class="refresh-input-control" :value="value" :placeholder="placeholder" :allow-clear="!disabled" :disabled="disabled" @update:value="emit('update:value', $event)"></a-input>
<fs-button :loading="loading" :disabled="disabled" type="primary" :text="buttonText" :icon="icon" @click="doRefresh"></fs-button>
</div>
<div class="helper" :class="{ error: hasError }">
{{ message }}
@@ -25,6 +25,7 @@ type RefreshInputProps = ComponentPropsType & {
icon?: string;
placeholder?: string;
successMessage?: string;
disabled?: boolean;
};
const fromType: any = inject("getFromType");
@@ -49,6 +50,9 @@ const placeholder = computed(() => props.placeholder || "");
const successMessage = computed(() => props.successMessage || "刷新成功,请保存配置");
const doRefresh = async () => {
if (props.disabled) {
return;
}
if (loading.value) {
return;
}
@@ -5,6 +5,7 @@ function createChallengeTypeDict() {
return dict({
data: [
{ value: "dns", label: $t("certd.verifyPlan.dnsChallenge"), color: "green" },
{ value: "dns-persist", label: "DNS持久验证", color: "cyan" },
{ value: "cname", label: $t("certd.verifyPlan.cnameProxyChallenge"), color: "blue" },
{ value: "http", label: $t("certd.verifyPlan.httpChallenge"), color: "yellow" },
],
@@ -39,7 +40,12 @@ export const Dicts = {
sslProviderDict: dict({
data: [
{ value: "letsencrypt", label: "Let's Encrypt" },
{ value: "letsencrypt_staging", label: "Let's Encrypt测试环境" },
{ value: "google", label: "Google" },
{ value: "zerossl", label: "ZeroSSL" },
{ value: "sslcom", label: "SSL.com" },
{ value: "litessl", label: "litessl" },
{ value: "custom", label: "自定义ACME" },
],
}),
get challengeTypeDict() {