perf: 新增自定义ACME管理功能,完善证书颁发机构适配

This commit is contained in:
xiaojunnuo
2026-08-22 20:47:02 +08:00
parent 4df94a4e2b
commit 2ab23f50b2
23 changed files with 420 additions and 163 deletions
@@ -123,14 +123,16 @@ export class SysPrivateSettings extends BaseSettings {
config: {},
};
// 证书颁发机构配置列表(内置 + 自定义ACME),内置项 builtIn=true 不允许修改删除
// 证书颁发机构配置列表(内置 + 自定义ACME),内置项 builtIn=true 不允许修改删除。
// 内置项不在此配置 Directory URL:其端点由运行时按加密算法(pkType)通过 acme.getDirectoryUrl 获取,
// 例如 ZeroSSL、SSL.com 的 RSA 与 EC 证书使用不同的端点,写死单一 URL 会导致 EC 申请走错端点。
customAcmeProviders?: CustomAcmeProvider[] = [
{ sslProvider: "letsencrypt", title: "Let's Encrypt", directoryUrl: "https://acme-v02.api.letsencrypt.org/directory", needEAB: false, builtIn: true },
{ sslProvider: "letsencrypt_staging", title: "Let's Encrypt测试环境", directoryUrl: "https://acme-staging-v02.api.letsencrypt.org/directory", needEAB: false, builtIn: true },
{ sslProvider: "google", title: "Google", directoryUrl: "https://dv.acme-v02.api.pki.goog/directory", needEAB: true, builtIn: true },
{ sslProvider: "zerossl", title: "ZeroSSL", directoryUrl: "https://acme.zerossl.com/v2/DV90", needEAB: true, builtIn: true },
{ sslProvider: "litessl", title: "litessl", directoryUrl: "https://acme.litessl.com/acme/v2/directory", needEAB: true, builtIn: true },
{ sslProvider: "sslcom", title: "SSL.com", directoryUrl: "https://acme.ssl.com/sslcom-dv-rsa", needEAB: true, builtIn: true },
{ sslProvider: "letsencrypt", title: "Let's Encrypt", directoryUrl: "", needEAB: false, builtIn: true },
{ sslProvider: "letsencrypt_staging", title: "Let's Encrypt测试环境", directoryUrl: "", needEAB: false, builtIn: true },
{ sslProvider: "google", title: "Google", directoryUrl: "", needEAB: true, builtIn: true },
{ sslProvider: "zerossl", title: "ZeroSSL", directoryUrl: "", needEAB: true, builtIn: true },
{ sslProvider: "litessl", title: "litessl", directoryUrl: "", needEAB: true, builtIn: true },
{ sslProvider: "sslcom", title: "SSL.com", directoryUrl: "", needEAB: true, builtIn: true },
];
removeSecret() {
@@ -76,18 +76,30 @@ const props = defineProps<
>();
const emit = defineEmits<{
"update:value": any;
"selected-change": any;
}>();
function updateValue(value: any) {
if (props.single === true) {
const last = value?.[value.length - 1];
emit("update:value", last);
emitSelectedChange(last);
selectRef.value.blur();
} else {
emit("update:value", value);
emitSelectedChange(value);
}
}
// emit("update:value", value);
/**
* 选中值变化时,通过 selected-change 事件把选中的 option 对象发射出去,
* 供字段 mergeScript 里 on: { "selected-change": (scope) => {...} } 联动使用
* (如根据选项的 needEAB 显隐其他字段)。单选发射单个 option,多选发射 option 数组。
*/
function emitSelectedChange(value: any) {
const values = Array.isArray(value) ? value : [value];
const options = values.map(v => optionsRef.value.find((item: any) => item.value === v)).filter(Boolean);
emit("selected-change", props.single === true ? options[0] : options);
}
const attrs = useAttrs();
@@ -50,7 +50,10 @@ export default {
customAcmeReverseProxy: "Reverse Proxy",
customAcmeReverseProxyHelper: "Optional; reverse proxy address when the ACME endpoint is not directly reachable, without http://",
customAcmeAdd: "Add Custom ACME",
customAcmeEdit: "Edit Custom ACME",
customAcmeNeedEab: "Need EAB",
customAcmeBuiltIn: "Built-in",
customAcmeTag: "Custom",
customAcmeDeleteConfirmTitle: "Delete Custom ACME",
customAcmeDeleteConfirmContent: "Deleting '{title}' may affect pipelines using it to apply certificates. Are you sure?",
customAcmeSaveCheck: "Name, sslProvider and Directory URL are required",
@@ -49,7 +49,10 @@ export default {
customAcmeReverseProxy: "反向代理地址",
customAcmeReverseProxyHelper: "选填;ACME端点无法直连时的反代地址,不带http://",
customAcmeAdd: "添加自定义ACME",
customAcmeEdit: "编辑自定义ACME",
customAcmeNeedEab: "需要EAB",
customAcmeBuiltIn: "内置",
customAcmeTag: "自定义",
customAcmeDeleteConfirmTitle: "删除自定义ACME",
customAcmeDeleteConfirmContent: "删除「{title}」可能影响使用它的流水线申请证书,确认是否删除?",
customAcmeSaveCheck: "自定义ACME的名称、标识、Directory URL均为必填",
@@ -1,6 +1,6 @@
import { checkPipelineLimit } from "/@/views/certd/pipeline/utils";
import { cloneDeep, merge, omit } from "lodash-es";
import { notification, Modal } from "ant-design-vue";
import { notification, Modal, message } from "ant-design-vue";
import { nanoid } from "nanoid";
import { useRouter } from "vue-router";
import { compute, CreateCrudOptionsRet, dict, useFormWrapper } from "@fast-crud/fast-crud";
@@ -237,7 +237,7 @@ export function useCertPipelineCreator({ formWrapperRef }: { formWrapperRef: Ref
content: pickCertApplyTemplateParams(form.input),
});
await reloadApplyTemplates();
notification.success({ message: "保存成功" });
// notification.success({ message: "保存成功" });
},
},
},
@@ -620,7 +620,8 @@ export function useCertPipelineCreator({ formWrapperRef }: { formWrapperRef: Ref
console.error(e);
}
}
notification.success({ message: "创建成功,请添加证书部署任务" });
//这里要用message 因为流水线详情页面,右上角有编辑保存按钮,Notification会把他们挡住
message.success("创建成功,请添加证书部署任务");
router.push({ path: "/certd/pipeline/detail", query: { id, editMode: "true" } });
}
const certPlugins = await getCertPlugins();
@@ -0,0 +1,171 @@
<template>
<div class="custom-acme-provider-list">
<div v-for="(provider, index) in props.modelValue" :key="index" class="custom-acme-provider-row">
<a-tag v-if="provider.builtIn" color="blue">{{ t("certd.sys.setting.customAcmeBuiltIn") }}</a-tag>
<a-tag v-else color="orange">{{ t("certd.sys.setting.customAcmeTag") }}</a-tag>
<span class="custom-acme-provider-title" :title="provider.directoryUrl">{{ provider.title }}</span>
<div class="custom-acme-provider-actions">
<a-button v-if="!provider.builtIn" type="link" size="small" @click="openEditor(index)">{{ t("common.edit") }}</a-button>
<a-button v-if="!provider.builtIn" type="link" size="small" danger @click="removeProvider(index)">{{ t("common.delete") }}</a-button>
</div>
</div>
<a-button class="custom-acme-provider-add" type="dashed" block @click="openEditor()">+ {{ t("certd.sys.setting.customAcmeAdd") }}</a-button>
</div>
</template>
<script setup lang="tsx">
import { useFormDialog } from "/@/use/use-dialog";
import { Modal, notification } from "ant-design-vue";
import { useI18n } from "/src/locales";
defineOptions({
name: "CustomAcmeProviderList",
});
export type CustomAcmeProviderForm = {
title: string;
sslProvider: string;
directoryUrl: string;
reverseProxy?: string;
needEAB?: boolean;
builtIn?: boolean;
};
const props = defineProps<{
modelValue: CustomAcmeProviderForm[];
}>();
const emit = defineEmits<{
"update:modelValue": [value: CustomAcmeProviderForm[]];
}>();
const { t } = useI18n();
const { openFormDialog } = useFormDialog();
const columns = {
title: {
title: t("certd.sys.setting.customAcmeName"),
type: "text",
form: {
rules: [{ required: true, message: t("certd.sys.setting.customAcmeSaveCheck") }],
},
},
sslProvider: {
title: t("certd.sys.setting.customAcmeSslProvider"),
type: "text",
form: {
rules: [{ required: true, message: t("certd.sys.setting.customAcmeSaveCheck") }],
helper: t("certd.sys.setting.customAcmeSslProviderHelper"),
},
},
directoryUrl: {
title: "Directory URL",
type: "text",
form: {
rules: [{ required: true, message: t("certd.sys.setting.customAcmeSaveCheck") }],
placeholder: "https://your-ca.example.com/directory",
},
},
reverseProxy: {
title: t("certd.sys.setting.customAcmeReverseProxy"),
type: "text",
form: {
required: false,
placeholder: "myca-proxy.example.com",
},
},
needEAB: {
title: t("certd.sys.setting.customAcmeNeedEab"),
component: {
name: "a-switch",
vModel: "checked",
},
form: {
value: false,
},
},
};
/**
* 添加(index 为空)或编辑(index 指定)自定义ACME
*/
async function openEditor(index?: number) {
const editing = index != null ? props.modelValue[index] : null;
await openFormDialog({
title: editing ? t("certd.sys.setting.customAcmeEdit") : t("certd.sys.setting.customAcmeAdd"),
columns,
initialForm: editing ? { ...editing } : { needEAB: false },
onSubmit: async (form: any) => {
// sslProvider 唯一性校验(编辑时排除自身)
const duplicated = props.modelValue.some((provider, i) => provider.sslProvider === form.sslProvider && i !== index);
if (duplicated) {
notification.warning({
message: t("certd.sys.setting.customAcmeSslProviderUnique"),
});
throw new Error(t("certd.sys.setting.customAcmeSslProviderUnique"));
}
const list = [...props.modelValue];
const bean: CustomAcmeProviderForm = {
title: form.title,
sslProvider: form.sslProvider,
directoryUrl: form.directoryUrl,
reverseProxy: form.reverseProxy || "",
needEAB: form.needEAB === true,
builtIn: false,
};
if (editing) {
list[index!] = bean;
} else {
list.push(bean);
}
// 通过 emit 同步给父组件,保证表单数据一致
emit("update:modelValue", list);
},
});
}
function removeProvider(index: number) {
const provider = props.modelValue[index];
if (!provider) {
return;
}
Modal.confirm({
title: t("certd.sys.setting.customAcmeDeleteConfirmTitle"),
content: t("certd.sys.setting.customAcmeDeleteConfirmContent", { title: provider.title }),
onOk() {
const list = [...props.modelValue];
list.splice(index, 1);
emit("update:modelValue", list);
},
});
}
</script>
<style lang="less">
.custom-acme-provider-list {
.custom-acme-provider-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border: 1px solid var(--border-color, #e8e8e8);
border-radius: 4px;
margin-bottom: 8px;
.custom-acme-provider-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.custom-acme-provider-actions {
display: flex;
align-items: center;
}
}
.custom-acme-provider-add {
margin-top: 4px;
}
}
</style>
@@ -60,29 +60,12 @@
<div class="helper">{{ t("certd.sys.setting.acmeWalkFromAuthoritativeHelper") }}</div>
</a-form-item>
<a-divider />
<div class="custom-acme-manage">
<div class="custom-acme-manage-title">{{ t("certd.sys.setting.customAcmeManage") }}</div>
<div class="helper">{{ t("certd.sys.setting.customAcmeManageHelper") }}</div>
<div v-for="(provider, index) in customAcmeProviders" :key="index" class="custom-acme-row">
<a-tag v-if="provider.builtIn" color="blue">{{ t("certd.sys.setting.customAcmeBuiltIn") }}</a-tag>
<a-input v-model:value="provider.title" :placeholder="t('certd.sys.setting.customAcmeName')" :disabled="provider.builtIn" />
<a-input v-model:value="provider.sslProvider" :placeholder="t('certd.sys.setting.customAcmeSslProvider')" :disabled="provider.builtIn" />
<a-input v-model:value="provider.directoryUrl" placeholder="https://your-ca.example.com/directory" :disabled="provider.builtIn" />
<a-input v-model:value="provider.reverseProxy" :placeholder="t('certd.sys.setting.customAcmeReverseProxy')" :disabled="provider.builtIn" />
<a-button v-if="!provider.builtIn" type="text" danger @click="removeCustomAcmeProvider(index)">
<template #icon>
<delete-outlined />
</template>
</a-button>
<a-form-item :label="t('certd.sys.setting.customAcmeManage')" :name="['private', 'customAcmeProviders']">
<div class="w-full">
<div class="helper">{{ t("certd.sys.setting.customAcmeManageHelper") }}</div>
<custom-acme-provider-list v-model="formState.private.customAcmeProviders" />
</div>
<div class="flex mt-10">
<a-button @click="addCustomAcmeProvider">+ {{ t("certd.sys.setting.customAcmeAdd") }}</a-button>
</div>
</div>
</a-form-item>
<a-form-item label=" " :colon="false" :wrapper-col="{ span: 8 }">
<a-button :loading="saveLoading" type="primary" html-type="submit">{{ t("certd.saveButton") }}</a-button>
@@ -92,13 +75,13 @@
</template>
<script setup lang="tsx">
import { reactive, ref, computed } from "vue";
import { reactive, ref } from "vue";
import { SysSettings } from "/@/views/sys/settings/api";
import * as api from "/@/views/sys/settings/api";
import { merge } from "lodash-es";
import { useSettingStore } from "/@/store/settings";
import { DeleteOutlined } from "@ant-design/icons-vue";
import { Modal, notification } from "ant-design-vue";
import CustomAcmeProviderList from "/@/views/sys/settings/components/custom-acme-provider-list.vue";
import { notification } from "ant-design-vue";
import { useI18n } from "/src/locales";
const { t } = useI18n();
@@ -123,66 +106,7 @@ const saveLoading = ref(false);
loadSysSettings();
const settingsStore = useSettingStore();
// 自定义ACME管理:随主表单一起保存(数据在 private.customAcmeProviders)
const customAcmeProviders = computed(() => formState.private?.customAcmeProviders || []);
function addCustomAcmeProvider() {
if (!formState.private) {
formState.private = {};
}
if (!formState.private.customAcmeProviders) {
formState.private.customAcmeProviders = [];
}
formState.private.customAcmeProviders.push({
title: "",
sslProvider: "",
directoryUrl: "",
reverseProxy: "",
needEAB: false,
builtIn: false,
});
}
function removeCustomAcmeProvider(index: number) {
const provider = customAcmeProviders.value[index];
if (!provider) {
return;
}
Modal.confirm({
title: t("certd.sys.setting.customAcmeDeleteConfirmTitle"),
content: t("certd.sys.setting.customAcmeDeleteConfirmContent", { title: provider.title }),
onOk() {
customAcmeProviders.value.splice(index, 1);
},
});
}
// 校验自定义ACME配置(内置项只读,无需校验)
function validateCustomAcmeProviders(): string | null {
const seen = new Set<string>();
for (const provider of customAcmeProviders.value) {
if (provider.builtIn) {
continue;
}
if (!provider.title || !provider.sslProvider || !provider.directoryUrl) {
return t("certd.sys.setting.customAcmeSaveCheck");
}
if (seen.has(provider.sslProvider)) {
return t("certd.sys.setting.customAcmeSslProviderUnique");
}
seen.add(provider.sslProvider);
}
return null;
}
const onFinish = async (form: any) => {
const checkMessage = validateCustomAcmeProviders();
if (checkMessage) {
notification.warning({
message: checkMessage,
});
return;
}
try {
saveLoading.value = true;
@@ -196,27 +120,4 @@ const onFinish = async (form: any) => {
}
};
</script>
<style lang="less">
.sys-settings-pipeline {
.custom-acme-manage {
margin-top: 10px;
.custom-acme-manage-title {
font-size: 16px;
font-weight: bold;
margin-bottom: 4px;
}
.custom-acme-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
.ant-input {
flex: 1;
}
}
}
}
</style>
<style lang="less"></style>
@@ -0,0 +1,16 @@
ALTER TABLE `pi_plugin` ADD COLUMN `app_id` bigint;
ALTER TABLE `pi_plugin` ADD COLUMN `developer_id` bigint;
ALTER TABLE `pi_plugin` ADD COLUMN `full_name` varchar(191);
ALTER TABLE `pi_plugin` ADD COLUMN `latest` varchar(100);
ALTER TABLE `pi_plugin` ADD COLUMN `status` varchar(100);
ALTER TABLE `pi_plugin` ADD COLUMN `download_count` bigint;
ALTER TABLE `pi_plugin` ADD COLUMN `score` real;
ALTER TABLE `pi_plugin` ADD COLUMN `sync_time` bigint;
CREATE UNIQUE INDEX `index_plugin_full_name` ON `pi_plugin` (`full_name`);
CREATE INDEX `index_plugin_plugin_type` ON `pi_plugin` (`pluginType`);
CREATE INDEX `index_plugin_group` ON `pi_plugin` (`group`);
CREATE INDEX `index_plugin_developer_id` ON `pi_plugin` (`developer_id`);
CREATE INDEX `index_plugin_sync_time` ON `pi_plugin` (`sync_time`);
UPDATE `pi_plugin` SET `type` = 'store' WHERE `type` = 'custom';
@@ -0,0 +1,22 @@
ALTER TABLE `pi_plugin` ADD COLUMN `installed` boolean NOT NULL DEFAULT true;
UPDATE `pi_plugin` SET `installed` = true;
ALTER TABLE `pi_plugin` ADD COLUMN `ai_check_status` varchar(32) NOT NULL DEFAULT '';
ALTER TABLE `pi_plugin` ADD COLUMN `vip` varchar(50);
-- 旧版插件没有 full_name,以作者和插件名回填,后续统一以 full_name 识别插件。
-- MySQL 中 || 默认是逻辑或,字符串拼接必须用 CONCAT。
UPDATE `pi_plugin`
SET `full_name` = CASE
WHEN `author` IS NOT NULL AND TRIM(`author`) <> '' THEN CONCAT(TRIM(`author`), '/', `name`)
ELSE `name`
END
WHERE `full_name` IS NULL OR TRIM(`full_name`) = '';
ALTER TABLE `cd_domain` ADD COLUMN `remark` varchar(500) NOT NULL DEFAULT '';
-- 在线插件依赖声明使用独立字段存储,避免塞进 extra YAML 解析合并。
-- 旧数据里 extra 中的 dependPlugins 由代码读取时兜底兼容,下次同步后自动迁移到新字段。
ALTER TABLE `pi_plugin` ADD COLUMN `depend_plugins` varchar(1024);
@@ -0,0 +1,11 @@
-- 证书仓库记录增加状态字段:active=激活,inactive=未激活,revoked=已吊销
ALTER TABLE cd_cert_info ADD COLUMN status varchar(20) NOT NULL DEFAULT 'active';
-- 吊销时间
ALTER TABLE cd_cert_info ADD COLUMN revoke_time bigint NULL;
-- 证书申请任务id:用于吊销旧证书时按任务id精确匹配(只吊销同一申请任务产出的旧证书)
ALTER TABLE cd_cert_info ADD COLUMN task_id varchar(100) NULL;
-- 证书仓库记录增加ACME账号授权id:吊销旧证书时直接使用记录上的账号,无需再解析流水线配置
ALTER TABLE cd_cert_info ADD COLUMN acme_account_access_id bigint NULL;
@@ -0,0 +1,16 @@
ALTER TABLE "pi_plugin" ADD COLUMN "app_id" bigint;
ALTER TABLE "pi_plugin" ADD COLUMN "developer_id" bigint;
ALTER TABLE "pi_plugin" ADD COLUMN "full_name" varchar(191);
ALTER TABLE "pi_plugin" ADD COLUMN "latest" varchar(100);
ALTER TABLE "pi_plugin" ADD COLUMN "status" varchar(100);
ALTER TABLE "pi_plugin" ADD COLUMN "download_count" bigint;
ALTER TABLE "pi_plugin" ADD COLUMN "score" real;
ALTER TABLE "pi_plugin" ADD COLUMN "sync_time" bigint;
CREATE UNIQUE INDEX "index_plugin_full_name" ON "pi_plugin" ("full_name");
CREATE INDEX "index_plugin_plugin_type" ON "pi_plugin" ("pluginType");
CREATE INDEX "index_plugin_group" ON "pi_plugin" ("group");
CREATE INDEX "index_plugin_developer_id" ON "pi_plugin" ("developer_id");
CREATE INDEX "index_plugin_sync_time" ON "pi_plugin" ("sync_time");
UPDATE "pi_plugin" SET "type" = 'store' WHERE "type" = 'custom';
@@ -0,0 +1,21 @@
ALTER TABLE "pi_plugin" ADD COLUMN "installed" boolean NOT NULL DEFAULT true;
UPDATE "pi_plugin" SET "installed" = true;
ALTER TABLE "pi_plugin" ADD COLUMN "ai_check_status" varchar(32) NOT NULL DEFAULT '';
ALTER TABLE "pi_plugin" ADD COLUMN "vip" varchar(50);
-- 旧版插件没有 full_name,以作者和插件名回填,后续统一以 full_name 识别插件。
UPDATE "pi_plugin"
SET "full_name" = CASE
WHEN "author" IS NOT NULL AND TRIM("author") <> '' THEN TRIM("author") || '/' || "name"
ELSE "name"
END
WHERE "full_name" IS NULL OR TRIM("full_name") = '';
ALTER TABLE "cd_domain" ADD COLUMN "remark" varchar(500) NOT NULL DEFAULT '';
-- 在线插件依赖声明使用独立字段存储,避免塞进 extra YAML 解析合并。
-- 旧数据里 extra 中的 dependPlugins 由代码读取时兜底兼容,下次同步后自动迁移到新字段。
ALTER TABLE "pi_plugin" ADD COLUMN "depend_plugins" varchar(1024);
@@ -0,0 +1,11 @@
-- 证书仓库记录增加状态字段:active=激活,inactive=未激活,revoked=已吊销
ALTER TABLE cd_cert_info ADD COLUMN status varchar(20) NOT NULL DEFAULT 'active';
-- 吊销时间
ALTER TABLE cd_cert_info ADD COLUMN revoke_time bigint NULL;
-- 证书申请任务id:用于吊销旧证书时按任务id精确匹配(只吊销同一申请任务产出的旧证书)
ALTER TABLE cd_cert_info ADD COLUMN task_id varchar(100) NULL;
-- 证书仓库记录增加ACME账号授权id:吊销旧证书时直接使用记录上的账号,无需再解析流水线配置
ALTER TABLE cd_cert_info ADD COLUMN acme_account_access_id bigint NULL;
@@ -1,6 +1,6 @@
ALTER TABLE "pi_plugin" ADD COLUMN "app_id" integer;
ALTER TABLE "pi_plugin" ADD COLUMN "developer_id" integer;
ALTER TABLE "pi_plugin" ADD COLUMN "full_name" varchar(200);
ALTER TABLE "pi_plugin" ADD COLUMN "full_name" varchar(191);
ALTER TABLE "pi_plugin" ADD COLUMN "latest" varchar(100);
ALTER TABLE "pi_plugin" ADD COLUMN "status" varchar(100);
ALTER TABLE "pi_plugin" ADD COLUMN "download_count" integer;
@@ -15,3 +15,7 @@ END
WHERE "full_name" IS NULL OR TRIM("full_name") = '';
ALTER TABLE "cd_domain" ADD COLUMN "remark" varchar(500) NOT NULL DEFAULT '';
-- 在线插件依赖声明使用独立字段存储,避免塞进 extra YAML 解析合并。
-- 旧数据里 extra 中的 dependPlugins 由代码读取时兜底兼容,下次同步后自动迁移到新字段。
ALTER TABLE "pi_plugin" ADD COLUMN "depend_plugins" varchar(1024);
@@ -1,3 +0,0 @@
-- 在线插件依赖声明使用独立字段存储,避免塞进 extra YAML 解析合并。
-- 旧数据里 extra 中的 dependPlugins 由代码读取时兜底兼容,下次同步后自动迁移到新字段。
ALTER TABLE "pi_plugin" ADD COLUMN "depend_plugins" varchar(4096);
@@ -1,4 +1,4 @@
import { getRuntimeDepsService } from "@certd/pipeline";
import { getRuntimeDepsService } from "@certd/pipeline";
import { ALL, Body, Controller, Inject, Post, Provide, Query, RequestIP } from "@midwayjs/core";
import { addonRegistry, AddonService, CrudController, SysPrivateSettings, SysPublicSettings, SysSafeSetting, SysSettingsEntity, SysSettingsService } from "@certd/lib-server";
import { cloneDeep, merge } from "lodash-es";
@@ -136,6 +136,10 @@ export class SysSettingsController extends CrudController<SysSettingsService> {
const privateSettings = await this.service.getPrivateSettings();
merge(publicSettings, body.public);
merge(privateSettings, body.private);
// customAcmeProviders 是数组,lodash merge 按下标合并,删除/调整顺序不会生效,需整体替换
if (body.private?.customAcmeProviders !== undefined) {
privateSettings.customAcmeProviders = body.private.customAcmeProviders;
}
await this.service.savePublicSettings(publicSettings);
await this.service.savePrivateSettings(privateSettings);
await this.auditLog({ content: "保存了系统设置" });
@@ -59,6 +59,13 @@ describe("AcmeAccountAccess", () => {
});
describe("AcmeAccountAccess custom CA", () => {
it("颁发机构为空时 getDirectoryUrl 给出明确提示", async () => {
const access = new AcmeAccountAccess();
access.caType = null as any;
await assert.rejects(() => access.getDirectoryUrl(), /请先选择颁发机构/);
});
it("custom caType 返回自定义 directoryUrl(旧版兼容)", async () => {
const access = new AcmeAccountAccess();
access.caType = "custom";
@@ -45,12 +45,18 @@ export class AcmeAccountAccess extends BaseAccess {
single: true,
},
required: true,
helper: "自定义ACME:适用于内网CA、企业CA等,由管理员在「系统设置-流水线设置」中配置",
mergeScript: `
return {
component: {
disabled: ctx.compute(({form})=> !!form.access?.account)
}
disabled: ctx.compute(({form})=> !!form.access?.account),
on: {
"selected-change": (scope)=>{
// 把选中颁发机构的 needEAB 同步到表单隐藏字段,用于控制 EAB 输入框显隐
let form = scope.form || {};
form.access._needEAB = scope.$event?.needEAB
}
}
},
}
`,
})
@@ -104,18 +110,8 @@ export class AcmeAccountAccess extends BaseAccess {
mergeScript: `
return {
show: ctx.compute(({form})=>{
const caType = form.access?.caType;
// 内置颁发机构按 needEAB 配置显隐;自定义ACME显示EAB(选填)
const builtInNeedEab = { letsencrypt: false, letsencrypt_staging: false, google: true, zerossl: true, sslcom: true, litessl: true };
if (caType && builtInNeedEab[caType] !== undefined) {
return builtInNeedEab[caType];
}
return !!caType;
}),
required: ctx.compute(({form})=>{
const caType = form.access?.caType;
// 内置需要EAB的颁发机构必填,自定义ACME选填
return ['google','zerossl','sslcom','litessl'].includes(caType);
// 按所选颁发机构的 needEAB 配置显隐(由 caType 的 onSelectedChange 同步到 _needEAB)
return form.access?._needEAB === true;
}),
component: {
disabled: ctx.compute(({form})=> !!form.access?.account)
@@ -135,18 +131,8 @@ export class AcmeAccountAccess extends BaseAccess {
mergeScript: `
return {
show: ctx.compute(({form})=>{
const caType = form.access?.caType;
// 内置颁发机构按 needEAB 配置显隐;自定义ACME显示EAB(选填)
const builtInNeedEab = { letsencrypt: false, letsencrypt_staging: false, google: true, zerossl: true, sslcom: true, litessl: true };
if (caType && builtInNeedEab[caType] !== undefined) {
return builtInNeedEab[caType];
}
return !!caType;
}),
required: ctx.compute(({form})=>{
const caType = form.access?.caType;
// 内置需要EAB的颁发机构必填,自定义ACME选填
return ['google','zerossl','sslcom','litessl'].includes(caType);
// 按所选颁发机构的 needEAB 配置显隐(由 caType 的 onSelectedChange 同步到 _needEAB)
return form.access?._needEAB === true;
}),
component: {
disabled: ctx.compute(({form})=> !!form.access?.account)
@@ -210,6 +196,9 @@ export class AcmeAccountAccess extends BaseAccess {
* - 自定义ACME:按 sslProvider 从「流水线设置」读取 directoryUrl
*/
async getDirectoryUrl() {
if (!this.caType) {
throw new Error("请先选择颁发机构");
}
if (this.caType === "custom") {
if (!this.directoryUrl) {
throw new Error("自定义ACME需要填写Directory URL");
@@ -287,6 +276,8 @@ export class AcmeAccountAccess extends BaseAccess {
sslProvider: this.caType as any,
// 自定义ACME时把 Directory URL 传给 AcmeService(生成账号必须走自定义端点);内置颁发机构走内置端点
directoryUrl: provider?.builtIn ? undefined : directoryUrl,
// 自定义ACME配置的反向代理地址,生成账号(访问directory/newAccount)时同样使用
reverseProxy: provider?.reverseProxy,
eab: externalAccountBinding ? ({ ...externalAccountBinding, id: 0 } as any) : undefined,
privateKeyType: "rsa_2048",
signal: (this.ctx as any)?.signal,
@@ -199,6 +199,36 @@ describe("AcmeService challenge", () => {
utils.http.request = originalRequest;
}
});
it("显式配置 reverseProxy 时强制启用 urlMapping,不再依赖直连测试", async () => {
const originalRequest = utils.http.request;
let requestCount = 0;
utils.http.request = async () => {
requestCount++;
return {};
};
try {
const service = new AcmeService({
userId: 1,
userContext: {} as any,
logger: logger as any,
sslProvider: "custom",
directoryUrl: "https://myca.example.com/directory",
reverseProxy: "myca-proxy.example.com",
domainParser: {} as any,
});
const urlMapping = await service.resolveUrlMapping("https://myca.example.com/directory");
// 配置了反向代理地址时直接启用,不进行直连测试
assert.equal(urlMapping.enabled, true);
assert.equal(urlMapping.mappings["myca.example.com"], "myca-proxy.example.com");
assert.equal(requestCount, 0);
} finally {
utils.http.request = originalRequest;
}
});
});
describe("AcmeService custom directoryUrl", () => {
@@ -201,6 +201,11 @@ export class AcmeService {
async resolveUrlMapping(directoryUrl: string) {
const urlMapping = this.buildUrlMapping(directoryUrl);
// 显式配置了反向代理地址(如自定义ACME配置的 reverseProxy)时,直接启用 urlMapping,保证反代始终生效
if (this.options.reverseProxy) {
urlMapping.enabled = true;
return urlMapping;
}
if (this.options.useMappingProxy) {
urlMapping.enabled = true;
return urlMapping;
@@ -224,7 +224,7 @@ describe("CertApplyPlugin custom 颁发机构", () => {
return {
sslProvider: "letsencrypt",
title: "Let's Encrypt",
directoryUrl: "https://acme-v02.api.letsencrypt.org/directory",
directoryUrl: "",
builtIn: true,
};
},
@@ -263,9 +263,9 @@ describe("CertApplyPlugin custom 颁发机构", () => {
serviceGetter: createCustomAcmeServiceGetter({
async getAll() {
return [
{ sslProvider: "letsencrypt", title: "Let's Encrypt", directoryUrl: "https://acme-v02.api.letsencrypt.org/directory", builtIn: true },
{ sslProvider: "google", title: "Google", directoryUrl: "https://dv.acme-v02.api.pki.goog/directory", builtIn: true },
{ sslProvider: "letsencrypt_staging", title: "Let's Encrypt测试环境", directoryUrl: "https://acme-staging-v02.api.letsencrypt.org/directory", builtIn: true },
{ sslProvider: "letsencrypt", title: "Let's Encrypt", directoryUrl: "", builtIn: true },
{ sslProvider: "google", title: "Google", directoryUrl: "", builtIn: true },
{ sslProvider: "letsencrypt_staging", title: "Let's Encrypt测试环境", directoryUrl: "", builtIn: true },
{ sslProvider: "myca", title: "我的CA", directoryUrl: "https://myca.example.com/directory", builtIn: false },
{ sslProvider: "myca2", title: "二号CA", directoryUrl: "https://myca2.example.com/directory", builtIn: false },
];
@@ -285,6 +285,20 @@ describe("CertApplyPlugin custom 颁发机构", () => {
assert.equal(values.includes("custom"), false);
});
it("颁发机构为空时 onInit 给出明确提示", async () => {
const plugin: any = new CertApplyPlugin();
plugin.version = 2;
plugin.sslProvider = null;
plugin.logger = { info() {}, warn() {}, error() {}, debug() {} };
plugin.ctx = {
serviceGetter: createCustomAcmeServiceGetter({}),
user: { id: 1 },
signal: undefined,
};
await assert.rejects(() => plugin.onInit(), /请先选择证书颁发机构/);
});
it("自定义颁发机构不支持 DNS 持久验证", async () => {
const plugin: any = new CertApplyPlugin();
plugin.version = 2;
@@ -257,15 +257,17 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
single: true,
value: "letsencrypt",
required: true,
helper:
"Let's Encrypt:申请最简单\nGoogle:大厂光环,兼容性好,仅首次需要翻墙获取EAB授权,无需翻墙\nSSL.com:仅主域名和www免费,必须设置CAA记录\n自定义ACME:适用于内网CA、企业CA等,由管理员在「系统设置-流水线设置」中配置",
helper: "Let's Encrypt:申请最简单\nGoogle:大厂光环,兼容性好,仅首次需要翻墙获取EAB授权,无需翻墙\nSSL.com:仅主域名和www免费,必须设置CAA记录\n自定义ACME:管理员可在「系统设置-流水线设置」中配置",
mergeScript: `return {
component:{
onSelectedChange: ctx.compute(({form})=>{
return ($event)=>{
on: {
"selected-change": (scope)=>{
// 切换颁发机构后,清空已选择的ACME账号,避免账号与颁发机构不匹配
let form = scope.form || {};
form = form.input || form.body || form;
form.acmeAccountAccessId = null
}
})
}
}
}
`,
@@ -569,11 +571,17 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
private acmeProvider?: CustomAcmeProvider;
async onInit() {
let eab: EabAccess = null;
// 解析当前颁发机构的系统配置(内置 + 自定义),自定义未配置时直接报错
this.acmeProvider = await this.getAcmeProvider();
}
private async getAcmeClient() {
if (this.acme) {
return this.acme;
}
let eab: EabAccess = null;
const isNewVersion = this.version === 2;
// 内置非 LE 颁发机构需要走EAB获取流程;自定义ACME无需EAB(其EAB在ACME账号授权中按需选填)
if (!isNewVersion && this.sslProvider && !this.sslProvider.startsWith("letsencrypt") && this.acmeProvider?.builtIn) {
@@ -603,6 +611,8 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
this.eab = eab;
const subDomainsGetter = await this.ctx.serviceGetter.get<ISubDomainsGetter>("subDomainsGetter");
const domainParser = new DomainParser(subDomainsGetter, this.logger);
// 解析当前颁发机构的系统配置(内置 + 自定义),自定义未配置时直接报错
this.acmeProvider = await this.getAcmeProvider();
this.acme = new AcmeService({
userId: this.ctx.user.id,
@@ -622,12 +632,16 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
domainParser,
waitDnsDiffuseTime: this.waitDnsDiffuseTime,
});
return this.acme;
}
/**
* 获取当前颁发机构在系统「流水线设置」中的配置(内置 + 自定义);未找到时给出明确报错
*/
private async getAcmeProvider(): Promise<CustomAcmeProvider> {
if (!this.sslProvider) {
throw new Error("请先选择证书颁发机构");
}
const customAcmeProviderService: any = await this.ctx.serviceGetter.get("customAcmeProviderService");
const provider = await customAcmeProviderService.getBySslProvider(this.sslProvider);
if (!provider) {
@@ -651,6 +665,7 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
}
async doCertApply() {
await this.getAcmeClient();
// 自定义ACME不支持DNS持久验证(_validation-persist 为 Let's Encrypt 特有机制)
if (this.acmeProvider && this.acmeProvider.builtIn !== true && this.challengeType === "dns-persist") {
throw new Error("自定义ACME不支持DNS持久验证,请改用其他域名验证方式");