mirror of
https://github.com/certd/certd.git
synced 2026-10-13 15:49:25 +08:00
perf: 新增自定义ACME管理功能,完善证书颁发机构适配
This commit is contained in:
@@ -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();
|
||||
|
||||
+171
@@ -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持久验证,请改用其他域名验证方式");
|
||||
|
||||
Reference in New Issue
Block a user