Compare commits

...
33 Commits
Author SHA1 Message Date
xiaojunnuo fcbf1045fe v1.45.0 2026-10-03 01:00:08 +08:00
xiaojunnuo f5cfe054b7 build: prepare to build 2026-10-03 00:51:15 +08:00
xiaojunnuo 5ded5ff2b6 test: 更新测试用例的断言数据和测试描述 2026-10-03 00:51:09 +08:00
xiaojunnuo 8d31346cd3 build: prepare to build 2026-10-03 00:22:38 +08:00
xiaojunnuo 44b41b6a08 chore(certd-server): 同步迁移脚本 2026-10-02 03:17:46 +08:00
xiaojunnuo 4420472f8f perf: 宝塔站点列表支持搜索和排序 2026-10-01 21:49:39 +08:00
xiaojunnuo c4497a7018 chore: 更新DNS持久验证的支持证书类型为Google证书
更新了多个位置的提示文本,将原Let's Encrypt测试环境的说明替换为Google证书,同时调整了验证方式的说明顺序和内容,还修改了插件加载模式为metadata,优化了代码中的格式细节。
2026-10-01 10:47:28 +08:00
xiaojunnuo 03f1a7bd0e chore: 修复获取证书颁发者时未处理空directoryUrl的问题
修改两处代码,在获取DNS持久化颁发者时,当directoryUrl为空时使用accountUri作为备用,避免因缺少必要参数导致的错误
2026-10-01 10:01:41 +08:00
xiaojunnuo 97d2b50074 fix: 支持google persist校验方式 2026-09-30 18:19:23 +08:00
xiaojunnuo 2d0b85b305 fix: 缺失运行时嵌套依赖时添加清除缓存提示
当加载运行时依赖时出现嵌套模块未找到错误,添加明确的错误提示,指导用户前往插件管理页面清除缓存依赖后重试。同时新增单元测试验证该提示逻辑。
2026-09-30 16:34:11 +08:00
xiaojunnuo 28052c9387 chore: clb tencent 2026-09-30 16:22:59 +08:00
xiaojunnuo c4277350bc perf(tencent clb): 修改所有CLB地域的endpoint为就近接入 2026-09-30 15:58:13 +08:00
xiaojunnuo 97160373d6 Merge branch 'v2-dev' of https://github.com/certd/certd into v2-dev 2026-09-30 15:32:57 +08:00
xiaojunnuo 0ce24618a1 docs: 修正HTTPS环境变量命名为小写格式 2026-09-29 15:06:06 +08:00
xiaojunnuo f60e63fbaf perf(monitor): 站点证书监控支持手动选中触发检查 2026-09-20 23:04:49 +08:00
xiaojunnuo 2735feb330 fix(webhook): 修复模版转义换行符,避免手动上传的证书报错
1.  将webhook插件内的replaceTemplate方法迁移到stringUtils公共工具
2.  优化模板替换处理,支持转义更多控制字符和兼容非字符串变量
3.  添加对应的单元测试用例
2026-09-20 23:04:13 +08:00
xiaojunnuo b6381d79fc chore: 批量优化插件开发相关配置与文档
1.  更新gitignore,新增.tmp_chunks忽略项
2.  切换certd插件加载模式为dev
3.  修正插件示例yaml的title注释
2026-09-20 01:26:02 +08:00
xiaojunnuo 241b2e5584 perf: 网宿插件双鉴权支持 2026-09-20 01:25:52 +08:00
xiaojunnuo bb1f232f41 fix: [BUG]使用天翼云CDN部署证书时无法匹配域名
https://github.com/certd/certd/issues/805
2026-09-19 18:05:22 +08:00
xiaojunnuo f82d3a5fcc chore(monitor): 补全站点新增/导入时的IP检查相关布尔默认值
解决批量导入等场景未传ipCheck等字段导致数据库存NULL,编辑弹窗开关过不了校验的issue #803,同时新增对应单测验证默认值和显式传参的正确性
2026-09-19 18:00:15 +08:00
xiaojunnuo bbeaf617d1 perf(open-api): openkey增加用途备注
1. 新增数据库字段存储开放接口密钥备注信息
2. 多语言添加密钥备注相关文案
3. 开放密钥接口支持备注参数传递与CRUD
4. 前端页面支持备注的展示、搜索与编辑
5. 调整插件加载模式为metadata并修复代码格式
2026-09-19 17:41:02 +08:00
xiaojunnuo ea7d14c289 fix(ctyun-cdn): 修复天翼云CDN部署插件的域名选择问题
1.  添加产品类型监听,切换产品类型时重新拉取域名列表
2.  未选择产品类型时抛出明确错误提示
3.  补全域名选项的domain字段以支持证书域名匹配分组
4.  新增单元测试覆盖上述场景
2026-09-18 01:12:36 +08:00
xiaojunnuo 3272a68ff6 style(certd client page): 调整头部布局和VIP按钮展示逻辑
优化页面头部子标题区域的对齐样式,移除非Plus版本的提示文本和条件渲染逻辑,统一展示VIP按钮
2026-09-18 00:18:13 +08:00
xiaojunnuo b5715d99ff Merge branch 'v2-dev' into v2_client 2026-09-17 23:52:49 +08:00
xiaojunnuo 3f5ba14fde ci(workflow): 修正stable-release.yml里版本号示例格式
将版本号示例的v前缀移除,和输入要求保持一致
2026-09-17 23:26:32 +08:00
xiaojunnuo 07f0d3b4d6 fix(config): 修复环境变量加载逻辑并更新配置文件
1.  重构loadDotEnv方法,支持指定加载的env文件路径,增加文件存在校验
2.  跳过注释和空行的环境变量加载,添加加载日志
3.  在默认配置中加载对应环境的env文件
4.  更新开发和生产环境的env配置项
2026-09-17 23:23:35 +08:00
xiaojunnuo 3dbbe72fc1 feat(client): 新增客户端管理功能
实现了客户端管理的完整流程:
1. 新增数据库表与迁移脚本,存储客户端信息与心跳快照
2. 实现服务端心跳上报接口与管理API
3. 新增前端页面与国际化多语言支持
4. 完善客户端在线状态判定逻辑与分页查询功能
2026-09-17 22:46:03 +08:00
xiaojunnuo 7f006d1359 refactor(plugin): 迁移同步插件逻辑到页面组件并优化上下文传递
1. 将plugin crud组件中的syncOnlinePlugins方法迁移到index.vue中
2. 把syncLoading和syncOnlinePlugins加入页面上下文传递给crud组件
3. 移除crud组件中冗余的syncLoading声明
4. 调整页面初始化调用顺序
2026-09-16 00:15:16 +08:00
xiaojunnuo d30f1c4785 refactor(plugin): 优化在线插件同步逻辑与时间格式化
1.  将自动同步相关逻辑抽离到页面组件统一管理
2.  使用dayjs替代原生Date进行时间格式化
3.  移除crud.tsx中冗余的同步配置加载代码
4.  统一维护自动同步的间隔时间常量
2026-09-15 23:52:29 +08:00
xiaojunnuo 96e0122b93 build: release 2026-09-12 20:26:56 +08:00
xiaojunnuo 7786a87b21 refactor(certd-server): 移除getReleaseMode函数,改用配置注入获取发布模式
1.  从环境配置文件中注释移除certd_release_mode的注释,启用稳定发布模式
2.  在AppController和SettingsController中注入release.mode配置替代原有的全局函数获取方式
3.  移除冗余的getReleaseMode工具函数
4.  在SettingsController中添加发布模式的日志打印
2026-09-12 20:22:18 +08:00
xiaojunnuo 34476ba14f build: publish 2026-09-12 19:24:56 +08:00
xiaojunnuo 4b942385a8 build: trigger build image 2026-09-12 19:24:46 +08:00
120 changed files with 2827 additions and 291 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ on:
workflow_dispatch:
inputs:
version:
description: "版本号(如 v1.42.5)"
description: "版本号(如 1.42.5)"
required: true
type: string
+2 -1
View File
@@ -42,4 +42,5 @@ output/
.uploads/
.certd-plugin-history/
.tmp
.plugin-dev/
.plugin-dev/
.tmp_chunks/
+60
View File
@@ -392,6 +392,66 @@ export class OAuthDemoAccess extends BaseAccess {
}
```
## 字段动态显隐(mergeScript)
多种鉴权方式共用一个授权时,用 `@AccessInput` 上的 `mergeScript` 按所选方式显隐字段:
```typescript
@AccessInput({
title: '鉴权方式',
value: 'apikey',
component: {
name: 'a-select',
vModel: 'value',
options: [
{ label: 'AccessKey签名', value: 'aksk' },
{ label: 'ApiKey', value: 'apikey' },
],
},
required: true,
})
authType = 'apikey';
@AccessInput({
title: 'accessKeyId',
component: { name: 'a-input', vModel: 'value' },
required: true,
mergeScript: `
return {
show: ctx.compute(({form})=>{
return !form.access.authType || form.access.authType === 'aksk';
})
}
`,
})
accessKeyId = '';
```
要点:
- `mergeScript` 返回的对象会深合并进本字段定义,可动态化的属性有 `show`、`required`、`helper`、
`component` 下的属性(`options`、`placeholder` 等)以及 `component.on` 事件。
- **动态判断必须写在 `ctx.compute(({form})=>{ ... })` 回调里**。`mergeScript` 本身在插件类
定义阶段就被拼成字符串,那时表单还没打开,用参数在服务端判断字段值没有意义。
- 授权表单字段值在 `form.access.xxx`(任务表单才是 `form.xxx`)。
- 历史授权没有新字段,条件里要用 `!form.access.authType || ...` 兜底走旧逻辑,保证老授权还能编辑保存。
- 联动其他字段用 `component.on` 事件回调,回调参数是 `{ form }`:
```typescript
mergeScript: `
return {
component: {
on: {
selectedChange: (scope) => {
const form = scope.form;
form.acmeAccountAccessId = null;
},
},
},
}
`,
```
## 注意事项
1. **插件命名**:插件名称应简洁明了,反映其功能。
+26 -4
View File
@@ -9,7 +9,7 @@ description: 用于通过 Certd API 开发、修改、读取、保存和恢复
本 Skill 使用 Certd HTTP API,不使用 WebSocket,也不依赖浏览器传递代码草稿。
- 前端只生成包含需求、API 地址和认证 Token 的启动提示词。
- 前端只生成包含需求、插件作者、API 地址和认证 Token 的启动提示词。
- Agent 直接调用 Certd API 读取和保存插件 YAML。
- Agent 的进度、日志和代码修改在 Codex/Trae 中查看。
- 插件开发临时文件和修改历史统一保存在 Agent 工作区的 `.plugin-dev/` 下,不保存到浏览器或 Certd 后端。
@@ -43,15 +43,13 @@ Certd 插件按来源分为三类:
## API 认证
提示词会提供 Certd API 地址和仅限 AI 插件开发接口的受限 Token。调用 API 时使用:
提示词会提供 Certd API 地址和仅限 AI 插件开发接口的受限的短时效的 Token。调用 API 时使用:
```http
Authorization: <token>
Content-Type: application/json
```
不要把 Token 写入代码、历史摘要、日志、提交信息或插件 YAML。
所有 Certd API 请求统一使用 Node.js 的 `fetch`。不要使用 PowerShell 的 `Invoke-RestMethod`、`Invoke-WebRequest` 或 .NET HTTP 客户端发送插件 YAML/JSON;它们在 Windows 上可能造成中文乱码或使完整 YAML 导入请求长时间无响应。
Node 请求须直接读取 UTF-8 文件或在 Node 内构造 JSON,并使用 `JSON.stringify`:
@@ -118,6 +116,7 @@ const response = await fetch(`${apiBase}/scoped/sys/ai/plugin/find`, {
- 修改后保存修改摘要。
- 恢复前再次备份当前版本。
- 不上传历史文件,不保存 Token、证书、私钥或真实授权值。
- 修改完一版后立即提交更新到certd,但不要发布到市场。
详细格式见 `references/local-history.md`。
@@ -125,6 +124,7 @@ const response = await fetch(`${apiBase}/scoped/sys/ai/plugin/find`, {
插件始终以完整 YAML 传递和保存,脚本源码放在顶层 `content` 字段。
- 新插件的 `author` 必须使用启动提示词中给出的插件作者,不要自行编造作者名;修改已有插件时保留原 `author`,不要改成其他作者。
- 统一使用 `await _ctx.import(...)` 引用模块。
- `"/@/..."` 表示以绝对路径引用 `server/src/` 下的模块。
- 最后返回继承目标基类的 class。
@@ -135,6 +135,28 @@ const response = await fetch(`${apiBase}/scoped/sys/ai/plugin/find`, {
需要字段格式时读取 `references/online-yaml-format.md`。
需要组件示例时读取 `references/component-examples.md`。
需要按其他字段动态显隐/必填/联动时读取 `references/merge-script.md`。
### 字段动态显隐(mergeScript)
字段定义支持 `mergeScript`:脚本 `return` 的对象会合并进本字段,用来做「选了 A 才显示 B」这类联动。
```yaml
mergeScript: |2-
return {
show: ctx.compute(({form})=>{
return form.access.authType === 'apikey';
})
}
```
- 动态逻辑必须写在 `ctx.compute(({form})=>{ ... })` 回调里;`mergeScript` 本身在插件类定义阶段就
被拼成字符串,那时表单还没打开,用参数在 node 侧判断字段值没有任何意义。
- 授权表单字段值在 `form.access.xxx`,任务表单字段值在 `form.xxx`。
- 新增可选字段时要给历史数据兜底,例如 `!form.access.authType || form.access.authType === 'aksk'`。
完整用法(能力、可动态化的属性、事件联动、常见错误、验证方式)见 `references/merge-script.md`。
## 示例插件
@@ -1,6 +1,6 @@
name: DemoAccess
icon: logos:airflow-icon
title: Demo-授权插件示例 # 模块-插件名
title: Demo-授权插件示例 # 格式:平台-功能 ,例如: 网宿-授权 ; 阿里云-授权 ; 天翼云-授权
group: null
desc: 这只是一个示例
version: 1.0.0
@@ -1,6 +1,6 @@
name: DemoDeploy
icon: logos:amp-icon
title: Demo-部署插件示例 # 模块-插件名
title: Demo-部署插件示例 # 格式:平台-功能 ,例如: 网宿-更新CDNpro证书 ; 阿里云-上传到CAS证书管理服务 ; 天翼云-更新CDN证书
group: cdn
desc: 这仅仅是一个示例
version: 1.0.0
@@ -1,6 +1,6 @@
name: DemoDnsProvider
icon: fa-solid:frog
title: Demo-Dns提供商插件示例 # 模块-插件名
title: Demo-Dns提供商插件示例 # 格式:平台-功能 ,例如: 阿里云-DNS提供商 ;腾讯云-DNS提供商
desc: 这只是一个示例
type: custom
version: 1.0.0
@@ -0,0 +1,181 @@
# mergeScript 用法
来源:前端 `packages/ui/certd-client/src/use/use-refrence.tsx`,类型定义来自 `@fast-crud/fast-crud`。
`mergeScript` 用来让输入字段的定义**依赖当前表单里的其他字段值动态变化**,例如:
选了某个鉴权方式才显示对应字段、切换类型后清空已选值、下拉选项跟着另一个字段变。
它写成字符串放在字段定义上(内置插件放在 `@AccessInput` / `@TaskInput` 里,在线插件放在 `input.<字段>` 下):
```ts
@AccessInput({
title: "API Key",
component: { name: "a-input", vModel: "value" },
mergeScript: `
return {
show: ctx.compute(({form})=>{
//access里面 要用 form.access.[字段名]
return form.access.authType === 'apikey';
//deploy插件里面要用form.[字段名]
return form.authType === 'apikey';
})
}
`,
})
apiKey = "";
```
## 执行时机
字段定义被前端加载时,对**每一个字段**执行一次:
```js
const func = new Function('ctx', formItem.mergeScript);
const merged = func(ctx); // 字段脚本只执行这一次
merge(formItem, merged); // 返回值深合并进该字段的定义
delete formItem.mergeScript; // 执行完就删掉,最终定义里不再有 mergeScript
```
要点:
- 脚本体内**必须 `return` 一个对象**,返回的对象会深合并到本字段的定义上。
- 脚本本身只在加载字段时跑一次,**拿不到、也不应该判断用户填了什么值**。
- 真正的动态逻辑写在返回对象里的 `ctx.compute(...)` 回调中,由 fast-crud 在渲染时求值。
## ctx 提供的能力
| 能力 | 说明 |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `ctx.compute(fn)` | 最常用。返回一个响应式的计算值:`fn` 里读到的表单字段会被自动追踪,字段变化时重新计算,视图跟着刷新 |
`ctx.compute` 的回调参数是一个 context 对象,常用字段:
- `form`:当前表单数据。**这是唯一常用的一个**。
- `row`:行数据。
- `mode`:表单模式 `add` / `edit` / `view`。
- `getComponentRef(key)`:取其他字段组件的 ref。
注意:Certd 在把 `form` 传进回调前做过一次兼容处理(`form.input || form.body || form`),
所以两种表单取值路径不同:
- **授权(Access)表单**:字段值在 `form.access.xxx`(字段 key 会被加上 `access.` 前缀)。
- **任务(Task)表单**:字段值直接是 `form.xxx`。
## 可以动态化的属性
返回的对象里,任何字段定义属性都可以用 `ctx.compute`,常用的有:
| 属性 | 用途 |
| -------------------- | ------------------------------------------------------------------------------------------------- |
| `show` | 显隐(最常用) |
| `required` | 动态必填,例如 `required: ctx.compute(({form}) => form.targetType === 'ingress')` |
| `disabled` | 动态禁用,例如账号已创建后不允许再改:`disabled: ctx.compute(({form}) => !!form.access?.account)` |
| `helper` | 动态帮助文案,回调里按条件 `return` 不同字符串 |
| `value` | 动态默认值 |
| `component` 下的属性 | 动态 `options`、`placeholder`、`inputKey`、`type`、`disabled` 等 |
| `component.on` | 事件处理,见下面「联动」 |
| `col` / `order` | 布局与排序 |
只写了一层 `show` 时最简形式就是本文开头的例子。
## 联动(监听事件并改动其他字段)
`component.on` 的事件回调**不**用 `ctx.compute` 包,它本身就是回调,参数是
`{ form, $event, ... }`;`form` 同样做过 `form.input || form.body || form` 的兼容处理。
事件名按组件而定,例如 `selectedChange`。
```ts
mergeScript: `
return {
component: {
on: {
selectedChange: (scope) => {
const form = scope.form;
// 切换颁发机构后清空 ACME 账号,避免账号与机构不匹配
form.acmeAccountAccessId = null;
},
},
},
}
`,
```
另一种写法是把回调也做成计算值(需要用到组件上配置的 `onSelectedChange` / `onChange` 属性):
```ts
mergeScript: `
return {
component: {
onSelectedChange: ctx.compute(({form}) => {
return ($event) => {
form.dnsProviderAccessType = $event.accessType;
};
}),
},
}
`,
```
两种写法都能用,优先参考同目录已有插件的写法,保持一致。
## 常见错误
- **不要用 node 侧变量决定条件**。`mergeScript` 是在插件类定义阶段(node 侧)拼出来的字符串,
此时表单还没打开,拿不到任何用户输入;用参数拼条件等于把逻辑写死在服务端。
条件必须原样落到 `ctx.compute` 回调里。
- **不要用 `if` 直接判断字段值**。回调必须 `return` 一个值,写成
`show: ctx.compute(({form}) => { if (...) { return true } })` 也可以,但不要漏掉 `return`。
- **不要漏掉历史数据兼容**。新增可选字段时,历史数据里没有这个字段(值为 `undefined`),
条件要按旧行为兜底,例如 `!form.access.authType || form.access.authType === 'aksk'`。
- **不要访问 `form.access` 以外的路径**去读授权字段,也不要在授权表单里按任务表单的路径取值。
- **不要依赖脚本执行时的副作用**(打印日志、改全局状态),它在每次打开表单时都会重新执行。
## YAML 写法(在线插件)
`mergeScript` 是含换行的脚本字符串,必须用 YAML 块标量:
```yaml
input:
apiKey:
title: API Key
component:
name: a-input
vModel: value
mergeScript: |2-
return {
show: ctx.compute(({form})=>{
return form.access.authType === 'apikey';
})
}
```
导出生成的缩进比较特殊(块标量内容统一缩进),**不要手工调整缩进**:
用脚本生成 YAML,并保证导出后再导入内容一致。转换成 block scalar 时注意:
- `|` / `|2-` 等块标量不需要转义换行,不要把脚本写成带 `\n` 的单行。
- 脚本里不要出现 `??` 这类容易被误判为乱码的写法,用显式 `if (x == null)` 代替。
## 验证方式
前端表单没法在插件开发流程里直接断言,可以在后端加一个 `src/**/*.test.ts`,
按前端 `useReference` 的路径执行字段上的 `mergeScript`:
```ts
const computedGetters = [];
const ctx = {
compute: getter => {
computedGetters.push(getter);
return getter;
},
asyncCompute: () => undefined,
computed: () => undefined,
};
new Function('ctx', mergeScript)(ctx);
// 用假的 form 求值,断言显隐结果
const show = computedGetters[0]({ form: { access: { authType: 'apikey' } } });
```
参考实现:`packages/ui/certd-server/src/plugins/plugin-wangsu/access-input.test.ts`。
要点是断言「同一个 show 函数换 `authType` 结果会变」,证明显隐确实是运行时算的,
而不是在 node 侧定死的。
@@ -7,7 +7,7 @@
```yaml
name: DemoTask
author: demo
title: Demo 任务
title: Demo-插件名称 # 格式:平台-功能 ,例如: 网宿-更新CDNpro证书 ; 阿里云-上传到CAS证书管理服务 ; 天翼云-更新CDN证书
desc: 插件说明
icon: clarity:plugin-line
pluginType: task
@@ -61,6 +61,8 @@ content: |
### 编辑规则
- 修改已有插件时保留 `name`、`author`、`pluginType` 和已有兼容字段。
- 新插件的 `author` 使用启动提示词中给出的插件作者,不要自行编造作者名;启动提示词未给出作者时先向用户确认。
- `fullName` 由 `author/name` 组成,作者变化会导致插件身份变化,不要随意修改。
- 只修改需求涉及的字段,避免删除未知的 YAML 字段。
- 脚本过长时仍放在同一个 `content` block scalar 中。
- 提交前检查 YAML 可解析、`content` 非空、版本和插件类型没有被意外修改。
@@ -57,4 +57,41 @@ return class DemoAccess extends BaseAccess {
- `onTestRequest` 应调用实际 API 方法并在失败时抛出异常。
- 对外 API 方法应统一处理分页、错误和返回字段。
- 使用 `this.logger` 或框架提供的 logger,禁止 `console.log`。
- 一般将API接口方法封装到Access中,其他plugin调用access的方法当做client sdk使用
- 一般将API接口方法封装到Access中,其他plugin调用access的方法当做client sdk使用
## 字段动态显隐
多种鉴权方式(例如「ApiKey」和「AccessKey签名」)共用一个授权时,
用字段上的 `mergeScript` 按所选方式显隐对应字段,只让用户看到当前方式需要的字段:
```yaml
input:
authType:
title: 鉴权方式
value: apikey
component:
name: a-select
vModel: value
options:
- { label: AccessKey签名, value: aksk }
- { label: ApiKey, value: apikey }
accessKeyId:
title: accessKeyId
component:
name: a-input
vModel: value
mergeScript: |2-
return {
show: ctx.compute(({form})=>{
return !form.access.authType || form.access.authType === 'aksk';
})
}
```
- 判断必须写在 `ctx.compute(({form})=>{ ... })` 回调里;脚本本身在插件定义阶段就拼好了,
那时表单还没打开,用参数在服务端判断字段值没有意义。
- 授权表单的字段值在 `form.access.xxx`(任务表单才是 `form.xxx`)。
- 历史授权没有 `authType` 字段,条件里要用 `!form.access.authType || ...` 兜底走旧逻辑。
完整用法见父 Skill 的 `references/merge-script.md`。
+23
View File
@@ -3,6 +3,29 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.45.0](https://github.com/certd/certd/compare/v1.44.4...v1.45.0) (2026-10-02)
### Bug Fixes
* [BUG]使用天翼云CDN部署证书时无法匹配域名 ([bb1f232](https://github.com/certd/certd/commit/bb1f232f415c522851dede979f5d609dda844fd3))
* 缺失运行时嵌套依赖时添加清除缓存提示 ([2d0b85b](https://github.com/certd/certd/commit/2d0b85b3054fc1aa82b3ab5db34a2a56308f71f2))
* 支持google persist校验方式 ([97d2b50](https://github.com/certd/certd/commit/97d2b5007409ec79ea864d437c473a2f9b5dfae4))
* **config:** 修复环境变量加载逻辑并更新配置文件 ([07f0d3b](https://github.com/certd/certd/commit/07f0d3b4d626628a0e3dfc7e5c52e8c4a98bad47))
* **ctyun-cdn:** 修复天翼云CDN部署插件的域名选择问题 ([ea7d14c](https://github.com/certd/certd/commit/ea7d14c289b8720781bf101bfef59eac7bb03765))
* **webhook:** 修复模版转义换行符,避免手动上传的证书报错 ([2735feb](https://github.com/certd/certd/commit/2735feb3302f8308708dafc8b295adcd4b91d7f0))
### Features
* **client:** 新增客户端管理功能 ([3dbbe72](https://github.com/certd/certd/commit/3dbbe72fc15c114ca6b5e8ff45cb21f258b80650))
### Performance Improvements
* 宝塔站点列表支持搜索和排序 ([4420472](https://github.com/certd/certd/commit/4420472f8fac272d30fb8ccb1cc2ee9763989001))
* 网宿插件双鉴权支持 ([241b2e5](https://github.com/certd/certd/commit/241b2e55848471e61b5d2be0c87a7dc9a0d29557))
* **monitor:** 站点证书监控支持手动选中触发检查 ([f60e63f](https://github.com/certd/certd/commit/f60e63fbaf00460d5050cd7705bd75d57dec202d))
* **open-api:** openkey增加用途备注 ([bbeaf61](https://github.com/certd/certd/commit/bbeaf617d19750515f6eca51863e74e23bc1de50))
* **tencent clb:** 修改所有CLB地域的endpoint为就近接入 ([c427735](https://github.com/certd/certd/commit/c4277350bcc8cf034cf4e76db2a6daa1842bc5d7))
## [1.44.4](https://github.com/certd/certd/compare/v1.44.3...v1.44.4) (2026-09-12)
### Bug Fixes
+13
View File
@@ -3,6 +3,19 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.44.4](https://github.com/certd/certd/compare/v1.44.3...v1.44.4) (2026-09-12)
### Bug Fixes
* 修复添加步骤会修改所有步骤标题的bug ([51e83e4](https://github.com/certd/certd/commit/51e83e4c38f7f319314fdc38ef33cf5720b2dde0))
* 修复证书插件配置无法打开的bug ([5563abb](https://github.com/certd/certd/commit/5563abb13a62140f4dee668ab768cf4ff9371ddb))
### Performance Improvements
* 新增余额管理,管理员充值功能 ([bded368](https://github.com/certd/certd/commit/bded368cbb8974b6937ae260e448c25fae5c33ad))
* **acme-account-access:** AcmeAccount授权允许修改eab,重新生成account ([6a649aa](https://github.com/certd/certd/commit/6a649aaca083ccd3b517f7d676a58220aa33b37d))
* **ui:** 商业版套餐购买不要求设置支付方式 ([e971145](https://github.com/certd/certd/commit/e9711456860d7db40914a0c295534f7bca56f83c))
## [1.44.3](https://github.com/certd/certd/compare/v1.44.2...v1.44.3) (2026-09-06)
### Bug Fixes
+2 -2
View File
@@ -5,8 +5,8 @@
`Certd`默认启用https,监听7002端口
如果你想关闭https,或者修改端口,可以在环境变量中配置
```shell
CERTD_HTTPS_ENABLE=true
CERTD_HTTPS_port=7002
certd_https_enabled=true
certd_https_port=7002
```
+1 -1
View File
@@ -9,5 +9,5 @@
}
},
"npmClient": "pnpm",
"version": "1.44.4"
"version": "1.45.0"
}
+6
View File
@@ -3,6 +3,12 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.45.0](https://github.com/publishlab/node-acme-client/compare/v1.44.4...v1.45.0) (2026-10-02)
### Bug Fixes
* 支持google persist校验方式 ([97d2b50](https://github.com/publishlab/node-acme-client/commit/97d2b5007409ec79ea864d437c473a2f9b5dfae4))
## [1.44.4](https://github.com/publishlab/node-acme-client/compare/v1.44.3...v1.44.4) (2026-09-12)
**Note:** Version bump only for package @certd/acme-client
+4 -3
View File
@@ -3,7 +3,7 @@
"description": "Simple and unopinionated ACME client",
"private": false,
"author": "nmorsman",
"version": "1.44.4",
"version": "1.45.0",
"type": "module",
"module": "./dist/index.js",
"main": "./dist/index.js",
@@ -18,7 +18,7 @@
"types"
],
"dependencies": {
"@certd/basic": "^1.44.4",
"@certd/basic": "^1.45.0",
"@peculiar/x509": "^1.11.0",
"asn1js": "^3.0.5",
"axios": "^1.9.0",
@@ -27,6 +27,7 @@
"https-proxy-agent": "^7.0.5",
"lodash-es": "^4.17.21",
"node-forge": "^1.3.1",
"psl": "^1.15.0",
"punycode.js": "^2.3.1"
},
"devDependencies": {
@@ -76,5 +77,5 @@
"bugs": {
"url": "https://github.com/publishlab/node-acme-client/issues"
},
"gitHead": "cc60cd4cd1d914110be65284bcf16cd3c5a63b8d"
"gitHead": "14809127d6cd3aa8e2a7c5fab6b1f35e95e45e43"
}
@@ -0,0 +1,21 @@
// @ts-ignore
import psl from "psl";
export function getDnsPersistIssuer(directoryUrl?: string) {
if (!directoryUrl) {
return "letsencrypt.org";
}
let directory: URL;
try {
directory = new URL(directoryUrl);
} catch {
throw new Error(`ACME Directory URL无效,无法生成DNS持久验证Issuer:${directoryUrl}`);
}
const issuer = psl.get(directory.hostname);
if (!issuer) {
throw new Error(`无法从ACME Directory URL获取可注册主域名:${directory.hostname}`);
}
return issuer;
}
+1
View File
@@ -79,3 +79,4 @@ export * from './verify.js'
export * from './error.js'
export * from './util.js'
export * from './dns-persist.js'
+6
View File
@@ -3,6 +3,12 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.45.0](https://github.com/certd/certd/compare/v1.44.4...v1.45.0) (2026-10-02)
### Bug Fixes
* **webhook:** 修复模版转义换行符,避免手动上传的证书报错 ([2735feb](https://github.com/certd/certd/commit/2735feb3302f8308708dafc8b295adcd4b91d7f0))
## [1.44.4](https://github.com/certd/certd/compare/v1.44.3...v1.44.4) (2026-09-12)
**Note:** Version bump only for package @certd/basic
+1 -1
View File
@@ -1 +1 @@
19:17
00:51
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@certd/basic",
"private": false,
"version": "1.44.4",
"version": "1.45.0",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
@@ -54,5 +54,5 @@
"tslib": "^2.8.1",
"typescript": "^5.4.2"
},
"gitHead": "cc60cd4cd1d914110be65284bcf16cd3c5a63b8d"
"gitHead": "14809127d6cd3aa8e2a7c5fab6b1f35e95e45e43"
}
@@ -1,6 +1,21 @@
import dayjs from "dayjs";
export const stringUtils = {
replaceTemplate(target: string, body: Record<string, unknown>, urlEncode = false) {
let bodyStr = target;
for (const key of Object.keys(body)) {
const rawValue = body[key] == null ? "" : String(body[key]);
let value = urlEncode ? encodeURIComponent(rawValue) : rawValue;
value = value.replace(/[\u0000-\u001f]/g, char => {
const escapes: Record<string, string> = { "\b": "\\b", "\f": "\\f", "\n": "\\n", "\r": "\\r", "\t": "\\t" };
return escapes[char] ?? `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`;
});
bodyStr = bodyStr.replaceAll(`\${${key}}`, value);
bodyStr = bodyStr.replaceAll(`{${key}}`, value);
}
return bodyStr;
},
maxLength(str?: string, length = 100) {
if (str) {
return str.length > length ? str.slice(0, length) + "..." : str;
+6
View File
@@ -3,6 +3,12 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.45.0](https://github.com/certd/certd/compare/v1.44.4...v1.45.0) (2026-10-02)
### Bug Fixes
* 缺失运行时嵌套依赖时添加清除缓存提示 ([2d0b85b](https://github.com/certd/certd/commit/2d0b85b3054fc1aa82b3ab5db34a2a56308f71f2))
## [1.44.4](https://github.com/certd/certd/compare/v1.44.3...v1.44.4) (2026-09-12)
**Note:** Version bump only for package @certd/pipeline
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "@certd/pipeline",
"private": false,
"version": "1.44.4",
"version": "1.45.0",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
@@ -21,8 +21,8 @@
"lint": "eslint --fix --ext .ts src"
},
"dependencies": {
"@certd/basic": "^1.44.4",
"@certd/plus-core": "^1.44.4",
"@certd/basic": "^1.45.0",
"@certd/plus-core": "^1.45.0",
"dayjs": "^1.11.7",
"lodash-es": "^4.17.21",
"reflect-metadata": "^0.2.2"
@@ -51,5 +51,5 @@
"tslib": "^2.8.1",
"typescript": "^5.4.2"
},
"gitHead": "cc60cd4cd1d914110be65284bcf16cd3c5a63b8d"
"gitHead": "14809127d6cd3aa8e2a7c5fab6b1f35e95e45e43"
}
@@ -64,6 +64,28 @@ describe("RuntimeDepsService", () => {
assert.equal(mod.value, 42);
});
it("logs a cache clearing hint when a nested runtime dependency is missing", async () => {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-deps-missing-nested-"));
const packageDir = path.join(rootDir, "node_modules", "runtime-only");
fs.mkdirSync(packageDir, { recursive: true });
fs.writeFileSync(path.join(rootDir, "package.json"), JSON.stringify({ name: "runtime-root", type: "module" }), "utf8");
fs.writeFileSync(path.join(packageDir, "package.json"), JSON.stringify({ name: "runtime-only", main: "index.js" }), "utf8");
fs.writeFileSync(path.join(packageDir, "index.js"), "module.exports = require('missing-nested');\n", "utf8");
const logs: string[] = [];
const logger = {
info() {},
warn() {},
error(message: string, ...args: any[]) {
logs.push([message, ...args.map(item => item?.message || String(item))].join(" "));
},
};
const service = new RuntimeDepsService({ rootDir }, null);
await assert.rejects(() => service.importRuntime("runtime-only", logger));
assert.ok(logs.some(message => message.includes("Cannot find module 'missing-nested'")));
assert.ok(logs.some(message => message.includes("插件管理页面->清除缓存依赖->重启certd")));
});
it("installs configured lazy dependency when import target is missing", async () => {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "certd-runtime-deps-lazy-"));
const service = new RuntimeDepsService({ rootDir, lazyDependencies: { "lazy-pkg": "^1.2.3" } }, null);
+11 -1
View File
@@ -251,6 +251,7 @@ export class RuntimeDepsService {
}
let resolved: string;
let fromRuntimeDeps = false;
try {
//尝试加载本项目依赖
resolved = this.resolveProjectSpecifier(specifier).resolved;
@@ -258,13 +259,22 @@ export class RuntimeDepsService {
if (!this.isModuleNotFoundError(error)) {
throw error;
}
fromRuntimeDeps = true;
resolved = await this.resolveLazyOrInstallSpecifier(specifier, logger);
}
if (!resolved) {
throw new Error(`依赖未安装成功: ${specifier}`);
}
return await import(pathToFileURL(resolved).href);
try {
return await import(pathToFileURL(resolved).href);
} catch (error: any) {
if (fromRuntimeDeps && this.isModuleNotFoundError(error)) {
logger.error?.("动态依赖加载失败:", error);
logger.error?.("动态依赖加载失败,请前往插件管理页面->清除缓存依赖->重启certd后重试");
}
throw error;
}
}
//动态获取模块解析
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.45.0](https://github.com/certd/certd/compare/v1.44.4...v1.45.0) (2026-10-02)
**Note:** Version bump only for package @certd/lib-huawei
## [1.44.4](https://github.com/certd/certd/compare/v1.44.3...v1.44.4) (2026-09-12)
**Note:** Version bump only for package @certd/lib-huawei
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@certd/lib-huawei",
"private": false,
"version": "1.44.4",
"version": "1.45.0",
"main": "./dist/bundle.js",
"module": "./dist/bundle.js",
"types": "./dist/d/index.d.ts",
@@ -31,5 +31,5 @@
"prettier": "3.3.3",
"tslib": "^2.8.1"
},
"gitHead": "cc60cd4cd1d914110be65284bcf16cd3c5a63b8d"
"gitHead": "14809127d6cd3aa8e2a7c5fab6b1f35e95e45e43"
}
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.45.0](https://github.com/certd/certd/compare/v1.44.4...v1.45.0) (2026-10-02)
**Note:** Version bump only for package @certd/lib-iframe
## [1.44.4](https://github.com/certd/certd/compare/v1.44.3...v1.44.4) (2026-09-12)
**Note:** Version bump only for package @certd/lib-iframe
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@certd/lib-iframe",
"private": false,
"version": "1.44.4",
"version": "1.45.0",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
@@ -37,5 +37,5 @@
"tslib": "^2.8.1",
"typescript": "^5.4.2"
},
"gitHead": "cc60cd4cd1d914110be65284bcf16cd3c5a63b8d"
"gitHead": "14809127d6cd3aa8e2a7c5fab6b1f35e95e45e43"
}
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.45.0](https://github.com/certd/certd/compare/v1.44.4...v1.45.0) (2026-10-02)
**Note:** Version bump only for package @certd/jdcloud
## [1.44.4](https://github.com/certd/certd/compare/v1.44.3...v1.44.4) (2026-09-12)
**Note:** Version bump only for package @certd/jdcloud
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@certd/jdcloud",
"version": "1.44.4",
"version": "1.45.0",
"description": "jdcloud openApi sdk",
"main": "./dist/bundle.js",
"module": "./dist/bundle.js",
@@ -63,5 +63,5 @@
"fetch"
]
},
"gitHead": "cc60cd4cd1d914110be65284bcf16cd3c5a63b8d"
"gitHead": "14809127d6cd3aa8e2a7c5fab6b1f35e95e45e43"
}
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.45.0](https://github.com/certd/certd/compare/v1.44.4...v1.45.0) (2026-10-02)
**Note:** Version bump only for package @certd/lib-k8s
## [1.44.4](https://github.com/certd/certd/compare/v1.44.3...v1.44.4) (2026-09-12)
**Note:** Version bump only for package @certd/lib-k8s
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "@certd/lib-k8s",
"private": false,
"version": "1.44.4",
"version": "1.45.0",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
@@ -21,7 +21,7 @@
"lint": "eslint --fix --ext .ts src"
},
"dependencies": {
"@certd/basic": "^1.44.4",
"@certd/basic": "^1.45.0",
"@kubernetes/client-node": "0.21.0"
},
"devDependencies": {
@@ -38,5 +38,5 @@
"tslib": "^2.8.1",
"typescript": "^5.4.2"
},
"gitHead": "cc60cd4cd1d914110be65284bcf16cd3c5a63b8d"
"gitHead": "14809127d6cd3aa8e2a7c5fab6b1f35e95e45e43"
}
+6
View File
@@ -3,6 +3,12 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.45.0](https://github.com/certd/certd/compare/v1.44.4...v1.45.0) (2026-10-02)
### Features
* **client:** 新增客户端管理功能 ([3dbbe72](https://github.com/certd/certd/commit/3dbbe72fc15c114ca6b5e8ff45cb21f258b80650))
## [1.44.4](https://github.com/certd/certd/compare/v1.44.3...v1.44.4) (2026-09-12)
### Bug Fixes
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@certd/lib-server",
"version": "1.44.4",
"version": "1.45.0",
"description": "midway with flyway, sql upgrade way ",
"private": false,
"type": "module",
@@ -29,11 +29,11 @@
],
"license": "AGPL",
"dependencies": {
"@certd/acme-client": "^1.44.4",
"@certd/basic": "^1.44.4",
"@certd/pipeline": "^1.44.4",
"@certd/plugin-lib": "^1.44.4",
"@certd/plus-core": "^1.44.4",
"@certd/acme-client": "^1.45.0",
"@certd/basic": "^1.45.0",
"@certd/pipeline": "^1.45.0",
"@certd/plugin-lib": "^1.45.0",
"@certd/plus-core": "^1.45.0",
"@midwayjs/cache": "3.14.0",
"@midwayjs/core": "3.20.11",
"@midwayjs/i18n": "3.20.13",
@@ -69,5 +69,5 @@
"typeorm": "^0.3.20",
"typescript": "^5.4.2"
},
"gitHead": "cc60cd4cd1d914110be65284bcf16cd3c5a63b8d"
"gitHead": "14809127d6cd3aa8e2a7c5fab6b1f35e95e45e43"
}
@@ -174,11 +174,11 @@ export class AccessService extends BaseService<AccessEntity> {
throw new ValidateException("userId不能为空");
}
if (userId !== entity.userId) {
throw new PermissionException("您对该Access授权无访问权限");
throw new PermissionException(`您对该Access授权${entity.name}(${entity.id})无访问权限`);
}
}
if (projectId != null && projectId !== entity.projectId) {
throw new PermissionException("您对该Access授权无访问权限");
throw new PermissionException(`您对该Access授权${entity.name}(${entity.id})无访问权限`);
}
// const access = accessRegistry.get(entity.type);
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.45.0](https://github.com/certd/certd/compare/v1.44.4...v1.45.0) (2026-10-02)
**Note:** Version bump only for package @certd/midway-flyway-js
## [1.44.4](https://github.com/certd/certd/compare/v1.44.3...v1.44.4) (2026-09-12)
**Note:** Version bump only for package @certd/midway-flyway-js
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@certd/midway-flyway-js",
"version": "1.44.4",
"version": "1.45.0",
"description": "midway with flyway, sql upgrade way ",
"private": false,
"type": "module",
@@ -52,5 +52,5 @@
"typeorm": "^0.3.20",
"typescript": "^5.4.2"
},
"gitHead": "cc60cd4cd1d914110be65284bcf16cd3c5a63b8d"
"gitHead": "14809127d6cd3aa8e2a7c5fab6b1f35e95e45e43"
}
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.45.0](https://github.com/certd/certd/compare/v1.44.4...v1.45.0) (2026-10-02)
**Note:** Version bump only for package @certd/plugin-cert
## [1.44.4](https://github.com/certd/certd/compare/v1.44.3...v1.44.4) (2026-09-12)
**Note:** Version bump only for package @certd/plugin-cert
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "@certd/plugin-cert",
"private": false,
"version": "1.44.4",
"version": "1.45.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -20,7 +20,7 @@
"lint": "eslint --fix --ext .ts src"
},
"dependencies": {
"@certd/plugin-lib": "^1.44.4"
"@certd/plugin-lib": "^1.45.0"
},
"devDependencies": {
"@types/chai": "^4.3.12",
@@ -38,5 +38,5 @@
"tslib": "^2.8.1",
"typescript": "^5.4.2"
},
"gitHead": "cc60cd4cd1d914110be65284bcf16cd3c5a63b8d"
"gitHead": "14809127d6cd3aa8e2a7c5fab6b1f35e95e45e43"
}
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.45.0](https://github.com/certd/certd/compare/v1.44.4...v1.45.0) (2026-10-02)
**Note:** Version bump only for package @certd/plugin-lib
## [1.44.4](https://github.com/certd/certd/compare/v1.44.3...v1.44.4) (2026-09-12)
**Note:** Version bump only for package @certd/plugin-lib
+5 -5
View File
@@ -1,7 +1,7 @@
{
"name": "@certd/plugin-lib",
"private": false,
"version": "1.44.4",
"version": "1.45.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -17,9 +17,9 @@
"lint": "eslint --fix --ext .ts src"
},
"dependencies": {
"@certd/acme-client": "^1.44.4",
"@certd/basic": "^1.44.4",
"@certd/pipeline": "^1.44.4",
"@certd/acme-client": "^1.45.0",
"@certd/basic": "^1.45.0",
"@certd/pipeline": "^1.45.0",
"dayjs": "^1.11.7",
"jszip": "^3.10.1",
"lodash-es": "^4.17.21",
@@ -45,5 +45,5 @@
"tslib": "^2.8.1",
"typescript": "^5.4.2"
},
"gitHead": "cc60cd4cd1d914110be65284bcf16cd3c5a63b8d"
"gitHead": "14809127d6cd3aa8e2a7c5fab6b1f35e95e45e43"
}
+11
View File
@@ -3,6 +3,17 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.45.0](https://github.com/certd/certd/compare/v1.44.4...v1.45.0) (2026-10-02)
### Features
* **client:** 新增客户端管理功能 ([3dbbe72](https://github.com/certd/certd/commit/3dbbe72fc15c114ca6b5e8ff45cb21f258b80650))
### Performance Improvements
* **monitor:** 站点证书监控支持手动选中触发检查 ([f60e63f](https://github.com/certd/certd/commit/f60e63fbaf00460d5050cd7705bd75d57dec202d))
* **open-api:** openkey增加用途备注 ([bbeaf61](https://github.com/certd/certd/commit/bbeaf617d19750515f6eca51863e74e23bc1de50))
## [1.44.4](https://github.com/certd/certd/compare/v1.44.3...v1.44.4) (2026-09-12)
### Bug Fixes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@certd/ui-client",
"version": "1.44.4",
"version": "1.45.0",
"private": true,
"scripts": {
"dev": "vite --open",
@@ -104,8 +104,8 @@
"zod-defaults": "^0.1.3"
},
"devDependencies": {
"@certd/lib-iframe": "^1.44.4",
"@certd/pipeline": "^1.44.4",
"@certd/lib-iframe": "^1.45.0",
"@certd/pipeline": "^1.45.0",
"@rollup/plugin-commonjs": "^25.0.7",
"@rollup/plugin-node-resolve": "^15.2.3",
"@types/chai": "^4.3.12",
@@ -19,6 +19,7 @@ import sysauthority from "./certd/sys-authority";
import syscname from "./certd/sys-cname";
import tutorial from "./certd/tutorial";
import cron from "./certd/cron";
import client from "./certd/client";
// Note: @ is reserved in locale messages; use {'@'} when needed.
export default {
@@ -43,4 +44,5 @@ export default {
...syscname,
...tutorial,
...cron,
...client,
};
@@ -19,6 +19,7 @@ export default {
subdomainConfirmContent: "{domain} appears to be a subdomain. Only delegated subdomains and free second-level subdomains need to be maintained here. Otherwise certificate application may fail. Continue?",
importFromProvider: "Import from Domain Provider",
importFromResolveRecords: "Import from DNS Records",
importFromResolveRecordsTooltip: "Import DNS records as monitoring sites",
syncExpirationDate: "Sync Domain Expiration Time",
syncTaskSubmitted: "Sync task submitted",
syncExpirationProgress: "Sync Domain Expiration Progress",
@@ -0,0 +1,20 @@
export default {
clientManager: "Client Manager",
client: {
title: "Client Manager",
description: "View online status and managed site count of connected Certd clients. Clients report a heartbeat every 10 minutes and are marked offline if no heartbeat arrives for 30 minutes.",
plusRequired: "This feature requires the Pro edition",
machineName: "Machine",
clientId: "Client ID",
version: "Version",
os: "OS",
status: "Status",
online: "Online",
offline: "Offline",
siteCount: "Sites",
httpsSiteCount: "HTTPS Sites",
syncedSiteCount: "Synced",
failedSiteCount: "Failed",
lastHeartbeatAt: "Last Heartbeat",
},
};
@@ -3,6 +3,9 @@ export default {
scopeOpenApiOnly: "Open API Only",
scopeFullAccount: "Full Account Permissions",
scopeHelper: "Open API only allows access to open APIs; full account permissions allow access to all APIs",
keyRemark: "Key Purpose",
keyRemarkHelper: "Describe the purpose of this key for easier identification",
keyRemarkMaxChars: "Maximum 512 characters",
gen: {
text: "API Test",
title: "x-certd-token",
@@ -7,6 +7,8 @@ export default {
settingLink: "Site Monitoring Settings",
limitInfo: "Basic edition limited to 1, professional and above unlimited, current",
checkAll: "Check All",
checkBatch: "Check Selected",
batchCheckConfirm: "Check the {count} selected sites now?",
checkNow: "Check Now",
batchDelete: "Batch Delete",
confirmTitle: "Confirm",
@@ -19,6 +19,7 @@ import sysauthority from "./certd/sys-authority";
import syscname from "./certd/sys-cname";
import tutorial from "./certd/tutorial";
import cron from "./certd/cron";
import client from "./certd/client";
// Note: @ is reserved in locale messages; use {'@'} when needed.
export default {
@@ -43,4 +44,5 @@ export default {
...syscname,
...tutorial,
...cron,
...client,
};
@@ -19,6 +19,7 @@ export default {
subdomainConfirmContent: "检测到{domain}为子域名,只有托管子域名和免费二级子域名才需要在此处维护,否则会导致申请证书失败,请确认是否继续?",
importFromProvider: "从域名提供商导入",
importFromResolveRecords: "从解析记录导入",
importFromResolveRecordsTooltip: "从域名的解析记录中导入并作为监控站点",
syncExpirationDate: "同步域名过期时间",
syncTaskSubmitted: "同步任务已提交",
syncExpirationProgress: "同步域名过期时间进度",
@@ -0,0 +1,20 @@
export default {
clientManager: "客户端管理",
client: {
title: "客户端管理",
description: "查看已接入的 Certd 客户端在线状态与管理的站点数量;客户端每 10 分钟上报一次心跳,超过 30 分钟未上报则标记为离线",
plusRequired: "此功能需要专业版",
machineName: "机器名",
clientId: "客户端ID",
version: "版本",
os: "系统",
status: "在线状态",
online: "在线",
offline: "离线",
siteCount: "站点数",
httpsSiteCount: "HTTPS站点",
syncedSiteCount: "已同步",
failedSiteCount: "异常",
lastHeartbeatAt: "上次心跳",
},
};
@@ -3,6 +3,9 @@ export default {
scopeOpenApiOnly: "仅开放接口",
scopeFullAccount: "账户所有权限",
scopeHelper: "仅开放接口只可以访问开放接口,账户所有权限可以访问所有接口",
keyRemark: "密钥用途",
keyRemarkHelper: "说明该密钥的用途,便于区分和查找",
keyRemarkMaxChars: "最大512个字符",
gen: {
text: "接口测试",
title: "x-certd-token",
@@ -7,6 +7,8 @@ export default {
settingLink: "站点监控设置",
limitInfo: "基础版限制1条,专业版以上无限制,当前",
checkAll: "检查全部",
checkBatch: "检查选中",
batchCheckConfirm: "确认触发检查选中的{count}个站点吗?",
checkNow: "立即检查",
batchDelete: "批量删除",
confirmTitle: "确认",
@@ -1,4 +1,4 @@
import { useProjectStore } from "/@/store/project";
import { useProjectStore } from "/@/store/project";
import { useSettingStore } from "/@/store/settings";
export const certdResources = [
@@ -128,6 +128,17 @@ export const certdResources = [
keepAlive: true,
},
},
{
title: "certd.clientManager",
name: "ClientManager",
path: "/cert/client",
component: "/certd/client/index.vue",
meta: {
icon: "ion:desktop-outline",
auth: true,
keepAlive: true,
},
},
{
title: "certd.sysResources.currentProject",
name: "ProjectMemberManager",
@@ -3,7 +3,7 @@
<template #header>
<div class="title">
DNS持久验证记录
<span class="red sub" style="color: red">当前仅 Let's Encrypt 测试环境 可以申请 DNS 持久验证证书。</span>
<span class="red sub" style="color: red">当前仅 Google证书 可以申请 DNS 持久验证。</span>
</div>
</template>
<fs-crud ref="crudRef" v-bind="crudBinding"></fs-crud>
@@ -235,7 +235,7 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
} else if (value === "dns-persist") {
Modal.confirm({
title: "请前往DNS持久验证记录页面添加记录",
content: "DNS持久验证需要先配置ACME账号和_validation-persist持久TXT记录,续期时不再增删DNS记录;当前仅 Let's Encrypt 测试环境 可以申请。",
content: "DNS持久验证需要先配置ACME账号和_validation-persist持久TXT记录,续期时不再增删DNS记录;当前仅 Google证书 可以申请。",
async onOk() {
router.push({
path: "/cert/cert/dns-persist",
@@ -0,0 +1,29 @@
import { request } from "/src/api/service";
const apiPrefix = "/client";
export const clientApi = {
async GetList(query: any) {
return await request({
url: apiPrefix + "/page",
method: "post",
data: query,
});
},
async GetObj(id: number) {
return await request({
url: apiPrefix + "/info",
method: "post",
params: { id },
});
},
async DelObj(id: number) {
return await request({
url: apiPrefix + "/delete",
method: "post",
params: { id },
});
},
};
@@ -0,0 +1,243 @@
// @ts-ignore
import { AddReq, CreateCrudOptionsProps, CreateCrudOptionsRet, DelReq, EditReq, UserPageQuery, UserPageRes } from "@fast-crud/fast-crud";
import dayjs from "dayjs";
import { useDicts } from "../dicts";
import { useProjectStore } from "/@/store/project";
import { useI18n } from "/src/locales";
import { clientApi } from "./api";
export default function ({ crudExpose, context }: CreateCrudOptionsProps): CreateCrudOptionsRet {
const { t } = useI18n();
const api = clientApi;
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
return await api.GetList(query);
};
const delRequest = async (req: DelReq) => {
const { row } = req;
return await api.DelObj(row.id);
};
const editRequest = async (req: EditReq) => {};
const addRequest = async (req: AddReq) => {};
const { myProjectDict } = useDicts();
const projectStore = useProjectStore();
return {
id: "clientCrud",
crudOptions: {
request: {
pageRequest,
addRequest,
editRequest,
delRequest,
},
pagination: {
pageSizeOptions: ["10", "20", "50", "100", "200"],
},
actionbar: {
buttons: {
add: {
show: false,
},
},
},
rowHandle: {
fixed: "right",
width: 120,
buttons: {
view: {
show: false,
},
edit: {
show: false,
},
},
},
search: {
initialForm: {
...projectStore.getSearchForm(),
},
},
columns: {
id: {
title: "ID",
key: "id",
type: "number",
search: {
show: false,
},
column: {
width: 70,
align: "center",
},
form: {
show: false,
},
},
machineName: {
title: t("certd.client.machineName"),
search: {
show: true,
},
type: "text",
form: {
show: false,
},
column: {
width: 180,
ellipsis: true,
},
},
clientId: {
title: t("certd.client.clientId"),
search: {
show: true,
},
type: "text",
form: {
show: false,
},
column: {
width: 220,
ellipsis: true,
},
},
version: {
title: t("certd.client.version"),
search: {
show: false,
},
type: "text",
form: {
show: false,
},
column: {
width: 90,
align: "center",
},
},
os: {
title: t("certd.client.os"),
search: {
show: false,
},
type: "text",
form: {
show: false,
},
column: {
width: 100,
align: "center",
},
},
online: {
title: t("certd.client.status"),
search: {
show: false,
},
type: "text",
form: {
show: false,
},
column: {
width: 100,
align: "center",
cellRender({ value, row }) {
const online = value === true;
const heartbeat = row.lastHeartbeatAt ? dayjs(row.lastHeartbeatAt).format("YYYY-MM-DD HH:mm:ss") : "";
return (
<a-tooltip title={heartbeat}>
<a-tag color={online ? "green" : "red"}>{online ? t("certd.client.online") : t("certd.client.offline")}</a-tag>
</a-tooltip>
);
},
},
},
siteCount: {
title: t("certd.client.siteCount"),
search: {
show: false,
},
type: "number",
form: {
show: false,
},
column: {
width: 90,
align: "center",
},
},
httpsSiteCount: {
title: t("certd.client.httpsSiteCount"),
search: {
show: false,
},
type: "number",
form: {
show: false,
},
column: {
width: 110,
align: "center",
},
},
syncedSiteCount: {
title: t("certd.client.syncedSiteCount"),
search: {
show: false,
},
type: "number",
form: {
show: false,
},
column: {
width: 90,
align: "center",
},
},
failedSiteCount: {
title: t("certd.client.failedSiteCount"),
search: {
show: false,
},
type: "number",
form: {
show: false,
},
column: {
width: 80,
align: "center",
},
},
lastHeartbeatAt: {
title: t("certd.client.lastHeartbeatAt"),
search: {
show: false,
},
type: "text",
form: {
show: false,
},
column: {
width: 170,
sorter: true,
cellRender({ value }) {
if (!value) {
return "-";
}
return dayjs(value).format("YYYY-MM-DD HH:mm:ss");
},
},
},
projectId: {
title: t("certd.fields.projectName"),
type: "dict-select",
dict: myProjectDict,
form: {
show: false,
},
},
},
},
};
}
@@ -0,0 +1,37 @@
<template>
<fs-page>
<template #header>
<div class="title flex items-center">
{{ t("certd.client.title") }}
<div class="sub flex items-center flex-1">
<div>
{{ t("certd.client.description") }}
</div>
<vip-button class="ml-5" mode="button"></vip-button>
</div>
</div>
</template>
<fs-crud ref="crudRef" v-bind="crudBinding"></fs-crud>
</fs-page>
</template>
<script lang="ts" setup>
import { useFs } from "@fast-crud/fast-crud";
import createCrudOptions from "./crud";
import { useMounted } from "/@/use/use-mounted";
import { useSettingStore } from "/@/store/settings";
import { useI18n } from "/src/locales";
const { t } = useI18n();
const settingStore = useSettingStore();
defineOptions({
name: "ClientManager",
});
const context: any = {
permission: {
isProjectPermission: true,
},
};
const { crudBinding, crudRef, crudExpose } = useFs({ createCrudOptions, context });
useMounted(() => crudExpose.doRefresh());
</script>
@@ -63,6 +63,13 @@ export const siteInfoApi = {
method: "post",
});
},
async CheckBatch(ids: number[]) {
return await request({
url: apiPrefix + "/checkBatch",
method: "post",
data: { ids },
});
},
async Import(form: any) {
return await request({
@@ -100,6 +100,23 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
});
}
function checkBatch() {
if (selectedRowKeys.value.length === 0) {
notification.error({ message: t("monitor.selectRecordsFirst") });
return;
}
Modal.confirm({
title: t("monitor.confirmTitle"),
content: t("monitor.batchCheckConfirm", { count: selectedRowKeys.value.length }),
onOk: async () => {
await siteInfoApi.CheckBatch(selectedRowKeys.value);
notification.success({ message: t("monitor.checkSubmitted") });
selectedRowKeys.value = [];
await crudExpose.doRefresh();
},
});
}
const GroupTypeSite = "site";
const groupDictRef = createGroupDictRef(GroupTypeSite);
@@ -253,6 +270,7 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
show: hasActionPermission("write"),
title: t("certd.domain.importFromResolveRecords"),
text: t("certd.domain.importFromResolveRecords"),
tooltip: { title: t("certd.domain.importFromResolveRecordsTooltip") },
type: "primary",
// needPlus: true,
color: "gold",
@@ -274,6 +292,15 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
checkAll();
},
},
checkBatch: {
show: true,
text: t("monitor.checkBatch"),
type: "primary",
icon: "ion:play-forward-outline",
click() {
checkBatch();
},
},
},
},
rowHandle: {
@@ -13,10 +13,15 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
return await api.GetList(query);
};
// 编辑允许修改备注与权限范围。
// keyId/keySecret 属于密钥本体,列表中 keySecret 是脱敏串,误提交会把真实密钥写坏,故不提交。
const editRequest = async (req: EditReq) => {
const { form, row } = req;
form.id = row.id;
const res = await api.UpdateObj(form);
const res = await api.UpdateObj({
id: row.id,
remark: form.remark,
scope: form.scope,
});
return res;
};
const delRequest = async (req: DelReq) => {
@@ -68,7 +73,7 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
buttons: {
view: { show: true },
copy: { show: false },
edit: { show: false },
edit: { show: true },
remove: { show: true },
gen: {
text: t("certd.gen.text"),
@@ -193,6 +198,22 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
sorter: true,
},
},
remark: {
title: t("certd.keyRemark"),
type: "text",
search: {
show: true,
},
column: {
width: 200,
sorter: true,
},
form: {
show: true,
helper: t("certd.keyRemarkHelper"),
rules: [{ max: 512, message: t("certd.keyRemarkMaxChars") }],
},
},
projectId: {
title: t("certd.fields.projectName"),
type: "dict-select",
@@ -21,7 +21,17 @@
</div>
</template>
<template v-else-if="step === 1"
><label class="plugin-ai-dev__label">开发需求</label><a-textarea v-model:value="requirement" :rows="6" /><a-button
><div v-if="mode === 'new'" class="plugin-ai-dev__field">
<label>插件作者</label>
<PluginAuthorField v-model="author" editable />
<div class="plugin-ai-dev__hint">自动读取绑定账号已注册的插件作者,也可以手动输入作者名称。</div>
</div>
<div v-else class="plugin-ai-dev__field">
<label>插件作者</label>
<a-input :value="author" disabled />
<div class="plugin-ai-dev__hint">修改已有插件时沿用该插件当前作者,作者变化会导致插件身份变化。</div>
</div>
<label class="plugin-ai-dev__label">开发需求</label><a-textarea v-model:value="requirement" :rows="6" /><a-button
class="plugin-ai-dev__generate"
type="primary"
:loading="creating"
@@ -120,6 +130,7 @@ import * as api from "../api";
import * as pipelineApi from "/@/views/certd/pipeline/api";
import * as historyApi from "/@/views/certd/pipeline/api.history";
import DependPluginsInput from "./depend-plugins-input.vue";
import PluginAuthorField from "./plugin-author-field.vue";
import PluginEditDialogBody from "./plugin-edit-dialog-body.vue";
import PipelineEditDialog from "/@/views/certd/pipeline/components/pipeline-edit-dialog.vue";
import { useFormDialog } from "/@/use/use-dialog";
@@ -135,6 +146,7 @@ const pluginTypeOptions = [
{ value: "notification", label: "通知插件", desc: "通过消息、邮件等方式发送通知" },
// { value: "addon", label: "Addon", desc: "扩展流水线的通用辅助能力" },
];
const author = ref("");
const requirement = ref(
"例如:开发一个部署证书到阿里云DCDN的插件\nAPI接口请参考:https://help.aliyun.com/zh/edge-security-acceleration/dcdn/developer-reference/api-dcdn-2018-01-15-overview。\n要求: \n1、能够引用上传到阿里云CAS步骤输出的证书。\n2、支持在UI上选择账户下某区域下的DCDN的域名列表。\n3、支持一次性选择多个域名进行部署"
);
@@ -183,7 +195,33 @@ async function createPrompt() {
try {
const token = await api.GetScopedAccessToken(["sys/ai"]);
accessToken.value = token.token;
prompt.value = `你是 Certd 在线插件开发 Agent。\n\n开发模式:${mode.value === "new" ? "开发新插件" : "修改已有插件"}\n插件类型:${pluginType.value || "按已有插件类型"}\n插件 ID:${pluginPath.value.at(-1) || "无"}\n用户需求:\n${requirement.value.trim()}\n\n如果用户需求描述与他选择的插件类型有冲突,你需要跟用户确认是否选错插件类型。 \nCertd 地址:${window.location.origin}\n受限 AccessToken(6小时有效):${token.token}\n\n请先读取 .trae/skills/certd-online-plugin-dev/SKILL.md,按插件类型开发并提交版本。仅调用 /scoped/sys/ai/plugin/ 前缀接口,完成后报告提交结果,不自动发布。\n如果当前工作目录不是 Certd 项目,或缺少 certd-online-plugin-dev Skill,先拉取 Certd 仓库代码并切换到仓库内工作(git clone https://atomgit.com/certd/certd --depth 1 )`;
const authorName = author.value.trim();
let authorText = authorName;
if (!authorText && mode.value === "edit") {
// 修改已有插件时作者保持原样,不能凭空补一个作者出来
authorText = "无(保持原插件的 author 字段不变,不要新增作者)";
}
if (!authorText) {
authorText = "未指定(请先向用户确认作者名称,不要自行编造作者名)";
}
prompt.value = `
你是 Certd 在线插件开发 Agent。
开发模式:${mode.value === "new" ? "开发新插件" : "修改已有插件"}
插件类型:${pluginType.value || "按已有插件类型"}
插件 ID:${pluginPath.value.at(-1) || "无"}
插件作者:${authorText}
用户需求:
${requirement.value.trim()}
如果用户需求描述与他选择的插件类型有冲突,你需要跟用户确认是否选错插件类型。
Certd 地址:${window.location.origin}
受限 AccessToken(6小时有效):${token.token}
请先读取 .trae/skills/certd-online-plugin-dev/SKILL.md,按插件类型开发并提交版本。仅调用 /scoped/sys/ai/plugin/ 前缀接口,完成后报告提交结果,不自动发布。
如果当前工作目录不是 Certd 项目,或缺少 certd-online-plugin-dev Skill,先拉取 Certd 仓库代码并切换到仓库内工作(git clone https://atomgit.com/certd/certd --depth 1 )
本次开发的在线插件无需写单元测试。
`;
notification.success({ message: "启动提示词已生成" });
} finally {
creating.value = false;
@@ -259,24 +297,31 @@ async function loadRecentPlugins(force = false) {
async function loadSelectedPlugin() {
const selectedValue = pluginPath.value.at(-1);
if (!selectedValue) {
recentPlugins.value = [];
setSelectedPlugin(null);
return;
}
if (props.pluginId && selectedValue === String(props.pluginId)) {
const selected = await api.GetObj(props.pluginId);
recentPlugins.value = selected ? [selected] : [];
setSelectedPlugin(selected);
return;
}
let selected = await api.GetObj(selectedValue);
if (selected) {
recentPlugins.value = [selected];
return;
if (!selected) {
const fullName = selectedValue.includes(":") ? selectedValue.slice(selectedValue.indexOf(":") + 1) : selectedValue;
const result: any = await api.FindPlugins({ includeBuiltIn: true, includeStore: true, keyword: fullName });
const records: any[] = Array.isArray(result) ? result : result?.records || [];
selected = records.find(item => String(item.fullName || item.name) === fullName) || records[0];
}
setSelectedPlugin(selected);
}
function setSelectedPlugin(plugin: any) {
recentPlugins.value = plugin ? [plugin] : [];
const pluginAuthor = String(plugin?.author || "").trim();
if (pluginAuthor) {
// 修改已有插件时,作者默认沿用该插件当前作者,避免插件身份被改错
author.value = pluginAuthor;
}
const fullName = selectedValue.includes(":") ? selectedValue.slice(selectedValue.indexOf(":") + 1) : selectedValue;
const result: any = await api.FindPlugins({ includeBuiltIn: true, includeStore: true, keyword: fullName });
const records: any[] = Array.isArray(result) ? result : result?.records || [];
selected = records.find(item => String(item.fullName || item.name) === fullName) || records[0];
recentPlugins.value = selected ? [selected] : [];
}
function formatTime(value: any) {
@@ -361,9 +406,9 @@ watch(mode, value => {
<style lang="less">
.plugin-ai-dev {
display: flex;
height: 60vh;
height: 65vh;
flex-direction: column;
gap: 20px;
gap: 10px;
&__steps {
flex: none;
@@ -373,7 +418,7 @@ watch(mode, value => {
display: flex;
flex: 1;
flex-direction: column;
gap: 18px;
gap: 10px;
padding: 8px 20px;
overflow-y: auto;
}
@@ -1,6 +1,6 @@
<template>
<div class="plugin-author-field">
<a-input :value="modelValue" placeholder="请输入作者名称" :disabled="authorRegistered" @update:value="emit('update:modelValue', $event)" />
<a-input :value="modelValue" placeholder="请输入作者名称" :disabled="inputDisabled" @update:value="emit('update:modelValue', $event)" />
<a-button type="link" size="small" :loading="loading" @click="handleAuthorAction">
{{ authorRegistered ? "修改邮箱" : "注册作者" }}
</a-button>
@@ -14,12 +14,22 @@ import { usePluginPublish } from "../use-publish";
defineOptions({ name: "PluginAuthorField" });
const props = defineProps<{ modelValue?: string }>();
const props = withDefaults(
defineProps<{
modelValue?: string;
// 允许手动输入作者名称,默认使用后端已注册作者且不可修改
editable?: boolean;
}>(),
{
editable: false,
}
);
const emit = defineEmits<{ (event: "update:modelValue", value: string): void }>();
const loading = ref(false);
const { registerPluginAuthor, updatePluginAuthorEmail } = usePluginPublish();
const registeredAuthor = ref<api.OnlinePluginAuthorBean>();
const authorRegistered = computed(() => !!registeredAuthor.value?.id);
const inputDisabled = computed(() => authorRegistered.value && !props.editable);
const isLocalAuthor = computed(() => {
return (
String(props.modelValue || "")
@@ -14,10 +14,13 @@ import { usePluginStore } from "/@/store/plugin";
import PluginAuthorField from "./components/plugin-author-field.vue";
import { usePluginAiDev } from "./use-ai-dev";
import { useOnlineInstall } from "./use-online-install";
import dayjs from "dayjs";
export default function ({ crudExpose, context }: CreateCrudOptionsProps): CreateCrudOptionsRet {
const { t } = useI18n();
const lastSyncTime = context.lastSyncTime;
const syncLoading = context.syncLoading;
const syncOnlinePlugins = context.syncOnlinePlugins;
let lastType = "";
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
if (lastType && lastType != query?.query?.type) {
@@ -65,10 +68,8 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
const settingStore = useSettingStore();
const pluginStore = usePluginStore();
const syncLoading = ref(false);
const upgradeAllLoading = ref(false);
const lastSyncTime = ref(0);
const autoSyncInterval = 30 * 24 * 60 * 60 * 1000;
const syncButtonTitle = computed(() => {
if (!lastSyncTime.value) {
return t("certd.onlinePluginNotSynced");
@@ -79,39 +80,13 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
});
function formatSyncTime(time: number) {
return new Date(time).toLocaleString();
}
function needAutoSync(time: number) {
if (!time) {
return true;
}
return Date.now() - time > autoSyncInterval;
return dayjs(time).format("YYYY-MM-DD HH:mm:ss");
}
function isEditablePlugin(row: any) {
return row.editable === true;
}
async function syncOnlinePlugins(options?: { showSuccess?: boolean }) {
if (syncLoading.value) {
return;
}
syncLoading.value = true;
try {
await api.OnlinePluginSync();
const setting = await api.OnlinePluginSetting();
lastSyncTime.value = setting.lastSyncTime || Date.now();
await pluginStore.reload();
crudExpose.doRefresh();
if (options?.showSuccess !== false) {
notification.success({ message: t("certd.onlinePluginSyncSuccess") });
}
} finally {
syncLoading.value = false;
}
}
async function upgradeAllOnlinePlugins() {
if (upgradeAllLoading.value) {
return;
@@ -157,18 +132,6 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
upgradeAllLoading.value = false;
}
}
async function loadOnlinePluginSetting() {
const setting = await api.OnlinePluginSetting();
lastSyncTime.value = setting.lastSyncTime || 0;
if (needAutoSync(lastSyncTime.value)) {
await syncOnlinePlugins({ showSuccess: false });
}
}
loadOnlinePluginSetting().catch(e => {
console.warn("load online plugin setting failed", e);
});
return {
crudOptions: {
settings: {
@@ -22,18 +22,42 @@ import PluginItemCard from "./components/plugin-item-card.vue";
import OnlinePluginDetail from "./components/online-plugin-detail.vue";
import { useI18n } from "/src/locales";
import { useMounted } from "/@/use/use-mounted";
import { computed, onBeforeUnmount, onMounted } from "vue";
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { usePluginStore } from "/@/store/plugin";
import { useFormDialog } from "/@/use/use-dialog";
import { notification } from "ant-design-vue";
import * as api from "./api";
const { t } = useI18n();
const pluginStore = usePluginStore();
const { openFormDialog } = useFormDialog();
const lastSyncTime = ref<number>(0);
const syncLoading = ref(false);
const autoSyncInterval = 3 * 24 * 60 * 60 * 1000;
defineOptions({
name: "SysPlugin",
});
const { crudBinding, crudRef, crudExpose } = useFs({ createCrudOptions });
async function syncOnlinePlugins(options?: { showSuccess?: boolean }) {
if (syncLoading.value) {
return;
}
syncLoading.value = true;
try {
await api.OnlinePluginSync();
const setting = await api.OnlinePluginSetting();
lastSyncTime.value = setting.lastSyncTime || Date.now();
await pluginStore.clear();
crudExpose.doRefresh();
if (options?.showSuccess !== false) {
notification.success({ message: t("certd.onlinePluginSyncSuccess") });
}
} finally {
syncLoading.value = false;
}
}
const context = { lastSyncTime, syncLoading, syncOnlinePlugins };
const { crudBinding, crudRef, crudExpose } = useFs({ createCrudOptions, context });
const pluginList = computed(() => {
return crudBinding.value?.data || [];
});
@@ -77,9 +101,25 @@ async function handlePluginChanged(payload: { action: string }) {
crudExpose.doRefresh();
}
function needAutoSync(time: number) {
if (!time) {
return true;
}
return Date.now() - time > autoSyncInterval;
}
async function autoSyncOnlinePlugins() {
const setting = await api.OnlinePluginSetting();
lastSyncTime.value = setting.lastSyncTime || 0;
if (needAutoSync(lastSyncTime.value)) {
await syncOnlinePlugins({ showSuccess: false });
}
}
// 页面打开后获取列表数据
useMounted(async () => {
await crudExpose.doRefresh();
autoSyncOnlinePlugins();
crudExpose.doRefresh();
});
onMounted(() => {
+1
View File
@@ -1 +1,2 @@
certd_plugin_loadmode=metadata
# certd_release_mode=dev
+22
View File
@@ -3,6 +3,28 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.45.0](https://github.com/certd/certd/compare/v1.44.4...v1.45.0) (2026-10-02)
### Bug Fixes
* [BUG]使用天翼云CDN部署证书时无法匹配域名 ([bb1f232](https://github.com/certd/certd/commit/bb1f232f415c522851dede979f5d609dda844fd3))
* 支持google persist校验方式 ([97d2b50](https://github.com/certd/certd/commit/97d2b5007409ec79ea864d437c473a2f9b5dfae4))
* **config:** 修复环境变量加载逻辑并更新配置文件 ([07f0d3b](https://github.com/certd/certd/commit/07f0d3b4d626628a0e3dfc7e5c52e8c4a98bad47))
* **ctyun-cdn:** 修复天翼云CDN部署插件的域名选择问题 ([ea7d14c](https://github.com/certd/certd/commit/ea7d14c289b8720781bf101bfef59eac7bb03765))
* **webhook:** 修复模版转义换行符,避免手动上传的证书报错 ([2735feb](https://github.com/certd/certd/commit/2735feb3302f8308708dafc8b295adcd4b91d7f0))
### Features
* **client:** 新增客户端管理功能 ([3dbbe72](https://github.com/certd/certd/commit/3dbbe72fc15c114ca6b5e8ff45cb21f258b80650))
### Performance Improvements
* 宝塔站点列表支持搜索和排序 ([4420472](https://github.com/certd/certd/commit/4420472f8fac272d30fb8ccb1cc2ee9763989001))
* 网宿插件双鉴权支持 ([241b2e5](https://github.com/certd/certd/commit/241b2e55848471e61b5d2be0c87a7dc9a0d29557))
* **monitor:** 站点证书监控支持手动选中触发检查 ([f60e63f](https://github.com/certd/certd/commit/f60e63fbaf00460d5050cd7705bd75d57dec202d))
* **open-api:** openkey增加用途备注 ([bbeaf61](https://github.com/certd/certd/commit/bbeaf617d19750515f6eca51863e74e23bc1de50))
* **tencent clb:** 修改所有CLB地域的endpoint为就近接入 ([c427735](https://github.com/certd/certd/commit/c4277350bcc8cf034cf4e76db2a6daa1842bc5d7))
## [1.44.4](https://github.com/certd/certd/compare/v1.44.3...v1.44.4) (2026-09-12)
### Performance Improvements
@@ -0,0 +1,24 @@
-- 客户端登记表:certd-client 心跳上报后记录在线状态;客户端自身信息保留独立列,站点统计等快照存 content JSON,后续扩展直接往 JSON 加字段。
CREATE TABLE `cd_client`
(
`id` bigint PRIMARY KEY AUTO_INCREMENT NOT NULL,
`user_id` bigint NOT NULL,
`project_id` bigint,
`client_id` varchar(64) NOT NULL,
`key_id` varchar(64),
`machine_name` varchar(255),
`version` varchar(64),
`os` varchar(64),
`content` longtext,
`last_heartbeat_at` bigint NOT NULL,
`create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE = InnoDB ROW_FORMAT = DYNAMIC;
CREATE INDEX `index_client_user_id` ON `cd_client` (`user_id`);
CREATE INDEX `index_client_project_id` ON `cd_client` (`project_id`);
CREATE UNIQUE INDEX `index_client_user_client` ON `cd_client` (`user_id`, `client_id`);
-- 开放接口密钥用途备注
ALTER TABLE cd_open_key ADD COLUMN `remark` varchar(512);
@@ -0,0 +1,24 @@
-- 客户端登记表:certd-client 心跳上报后记录在线状态;客户端自身信息保留独立列,站点统计等快照存 content JSON,后续扩展直接往 JSON 加字段。
CREATE TABLE "cd_client"
(
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY NOT NULL,
"user_id" bigint NOT NULL,
"project_id" bigint,
"client_id" varchar(64) NOT NULL,
"key_id" varchar(64),
"machine_name" varchar(255),
"version" varchar(64),
"os" varchar(64),
"content" text,
"last_heartbeat_at" bigint NOT NULL,
"create_time" timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP),
"update_time" timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP)
);
CREATE INDEX "index_client_user_id" ON "cd_client" ("user_id");
CREATE INDEX "index_client_project_id" ON "cd_client" ("project_id");
CREATE UNIQUE INDEX "index_client_user_client" ON "cd_client" ("user_id", "client_id");
-- 开放接口密钥用途备注
ALTER TABLE cd_open_key ADD COLUMN "remark" varchar(512);
@@ -0,0 +1,24 @@
-- 客户端登记表:certd-client 心跳上报后记录在线状态;客户端自身信息保留独立列,站点统计等快照存 content JSON,后续扩展直接往 JSON 加字段。
CREATE TABLE "cd_client"
(
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
"user_id" integer NOT NULL,
"project_id" integer,
"client_id" varchar(64) NOT NULL,
"key_id" varchar(64),
"machine_name" varchar(255),
"version" varchar(64),
"os" varchar(64),
"content" text,
"last_heartbeat_at" integer NOT NULL,
"create_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP),
"update_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP)
);
CREATE INDEX "index_client_user_id" ON "cd_client" ("user_id");
CREATE INDEX "index_client_project_id" ON "cd_client" ("project_id");
CREATE UNIQUE INDEX "index_client_user_client" ON "cd_client" ("user_id", "client_id");
-- 开放接口密钥用途备注
ALTER TABLE cd_open_key ADD COLUMN "remark" varchar(512);
+13 -3
View File
@@ -76,15 +76,20 @@ async function genMetadata() {
fs.rmSync("./metadata", { recursive: true });
fs.mkdirSync("./metadata", { recursive: true });
const warnings = []
for (const key in modules) {
const module = modules[key];
const entry = Object.entries(module);
if (entry.length > 1) {
console.log(`[warning] 文件 ${key} 导出了 ${entry.length} 个对象: ${entry.map(([name, value]) => name).join(", ")}`);
}
// if (entry.length > 1) {
// warnings.push(`[warning] 文件 ${key} 导出了 ${entry.length} 个对象: ${entry.map(([name, value]) => name).join(", ")}`);
// }
const isManyExport = entry.length > 1;
for (const [name, value] of entry) {
//如果有define属性
if (value.define) {
if (isManyExport) {
throw new Error(`插件文件禁止导出多个对象, ${key} 导出了 ${entry.length} 个对象: ${entry.map(([name, value]) => name).join(", ")}`);
}
//那么就是插件
let location = key.substring(4);
location = location.substring(0, location.length - 3);
@@ -121,6 +126,11 @@ async function genMetadata() {
}
}
}
if (warnings.length > 0) {
for (const warning of warnings) {
console.log(warning);
}
}
}
async function genPluginMd() {
@@ -3,25 +3,87 @@ title: 网宿授权
desc: ''
icon: svg:icon-wangsu
input:
authType:
title: 鉴权方式
value: aksk
component:
name: a-select
vModel: value
allowClear: false
options:
- label: AccessKey签名
value: aksk
- label: ApiKey(CDN Pro必须用这个)
value: apikey
helper: 网宿接口鉴权方式
required: false
accessKeyId:
title: accessKeyId
component:
name: a-input
vModel: value
placeholder: accessKeyId
component:
name: a-input
vModel: value
helper: '[点击前往获取AccessKey](https://console.wangsu.com/account/accessKey?rsr=ws)'
encrypt: false
required: true
mergeScript: |2-
return {
show: ctx.compute(({form})=>{
return !form.access.authType || form.access.authType === 'aksk';
})
}
accessKeySecret:
title: accessKeySecret
component:
name: a-input
vModel: value
placeholder: accessKeySecret
component:
name: a-input
vModel: value
encrypt: true
required: true
mergeScript: |2-
return {
show: ctx.compute(({form})=>{
return !form.access.authType || form.access.authType === 'aksk';
})
}
apiUser:
title: API账号名
component:
name: a-input
vModel: value
placeholder: API账号名
helper: 控制台里的 API 账号名,与 API Key 配套使用
encrypt: false
required: false
mergeScript: |2-
return {
show: ctx.compute(({form})=>{
return form.access.authType === 'apikey';
})
}
apiKey:
title: API Key
component:
name: a-input
vModel: value
placeholder: API Key
helper: 控制台里的 API Key,[点击前往获取](https://console.wangsu.com/account/apiManage)
encrypt: true
required: false
mergeScript: |2-
return {
show: ctx.compute(({form})=>{
return form.access.authType === 'apikey';
})
}
testRequest:
title: 测试
component:
@@ -59,6 +59,7 @@ input:
vModel: value
mode: tags
action: GetSiteList
search: true
watches:
- certDomains
- accessId
@@ -80,17 +80,17 @@ input:
helper: >
1. <b>DNS直接验证</b>:当域名dns解析已被本系统支持时(即下方DNS解析服务商选项中可选),推荐选择此方式
2.
2. <b>DNS持久验证</b>:需要先配置ACME账号和_validation-persist持久TXT记录,续期时不再增删DNS记录;当前仅
Google证书 可以申请
3.
<b>CNAME代理验证</b>:支持任何注册商的域名,第一次需要手动添加[CNAME记录](#/certd/cname/record)(如果经常申请失败,建议将DNS服务器修改为阿里云/腾讯云的,然后使用DNS直接验证)
3. <b>HTTP文件验证</b>:不支持泛域名,需要配置网站文件上传(IP证书必须选它)
4. <b>HTTP文件验证</b>:不支持泛域名,需要配置网站文件上传(IP证书必须选它)
4. <b>多DNS提供商</b>:每个域名可以选择独立的DNS提供商
5. <b>多DNS提供商</b>:每个域名可以选择独立的DNS提供商或校验方式
5. <b>自动匹配</b>:此处无需选择校验方式,需要在[域名管理](#/certd/cert/domain)中提前配置好校验方式
6. <b>DNS持久验证</b>:需要先配置ACME账号和_validation-persist持久TXT记录,续期时不再增删DNS记录;当前仅
Let's Encrypt 测试环境 可以申请
6. <b>自动匹配</b>:此处无需选择校验方式,需要在[域名管理](#/certd/cert/domain)中提前配置好校验方式
order: 0
dnsProviderType:
title: DNS解析服务商
@@ -80,6 +80,7 @@ input:
watches:
- certDomains
- accessId
- productCode
required: true
mergeScript: |2-
+14 -14
View File
@@ -1,6 +1,6 @@
{
"name": "@certd/ui-server",
"version": "1.44.4",
"version": "1.45.0",
"description": "fast-server base midway",
"private": true,
"type": "module",
@@ -43,20 +43,20 @@
"lint1": "eslint --fix --ext .ts src"
},
"dependencies": {
"@certd/acme-client": "^1.44.4",
"@certd/basic": "^1.44.4",
"@certd/commercial-core": "^1.44.4",
"@certd/acme-client": "^1.45.0",
"@certd/basic": "^1.45.0",
"@certd/commercial-core": "^1.45.0",
"@certd/cv4pve-api-javascript": "^8.4.2",
"@certd/jdcloud": "^1.44.4",
"@certd/lib-huawei": "^1.44.4",
"@certd/lib-k8s": "^1.44.4",
"@certd/lib-server": "^1.44.4",
"@certd/midway-flyway-js": "^1.44.4",
"@certd/pipeline": "^1.44.4",
"@certd/plugin-cert": "^1.44.4",
"@certd/plugin-lib": "^1.44.4",
"@certd/plugin-plus": "^1.44.4",
"@certd/plus-core": "^1.44.4",
"@certd/jdcloud": "^1.45.0",
"@certd/lib-huawei": "^1.45.0",
"@certd/lib-k8s": "^1.45.0",
"@certd/lib-server": "^1.45.0",
"@certd/midway-flyway-js": "^1.45.0",
"@certd/pipeline": "^1.45.0",
"@certd/plugin-cert": "^1.45.0",
"@certd/plugin-lib": "^1.45.0",
"@certd/plugin-plus": "^1.45.0",
"@certd/plus-core": "^1.45.0",
"@koa/cors": "^5.0.0",
"@midwayjs/bootstrap": "3.20.11",
"@midwayjs/cache": "3.14.0",
@@ -249,6 +249,7 @@ const development = {
],
},
} as MidwayConfig;
loadDotEnv(`.env.${env}`);
loadDotEnv();
mergeConfig(development, "development");
+10 -2
View File
@@ -47,10 +47,18 @@ export function mergeConfig(config: any, envType: string) {
return config;
}
export function loadDotEnv() {
const envStr = fs.readFileSync(".env").toString();
export function loadDotEnv(envName = ".env") {
if (!fs.existsSync(envName)) {
return;
}
logger.info("load env file:", envName);
const envStr = fs.readFileSync(envName).toString();
envStr.split("\n").forEach(line => {
const [key, value] = line.trim().split("=");
if (key.trim().startsWith("#") || key.trim() === "") {
return;
}
logger.info("load env var:", key, value);
const oldValue = process.env[key];
if (!oldValue) {
process.env[key] = value;
@@ -116,11 +116,10 @@ export class MainConfiguration {
//授权处理
AuthorityMiddleware,
AuditLogMiddleware,
//resetPasswd,重置密码模式下不提供服务
ResetPasswdMiddleware,
]);
this.app.getMiddleware().insertFirst(async (ctx: IMidwayKoaContext, next: NextFunction) => {
await next();
const path = ctx.path;
@@ -3,7 +3,7 @@
import assert from "node:assert/strict";
import { getReleaseMode, normalizeReleaseVersion } from "./app-controller.js";
import { normalizeReleaseVersion } from "./app-controller.js";
describe("AppController.normalizeReleaseVersion", () => {
it("normalizes AtomGit release tag names", () => {
@@ -15,29 +15,3 @@ describe("AppController.normalizeReleaseVersion", () => {
assert.equal(normalizeReleaseVersion({ name: "v1.40.0" }), "1.40.0");
});
});
describe("AppController.getReleaseMode", () => {
it("returns 'latest' when env var is not set", () => {
delete process.env.certd_release_mode;
assert.equal(getReleaseMode(), "latest");
});
it("returns 'latest' when env var is empty", () => {
process.env.certd_release_mode = "";
assert.equal(getReleaseMode(), "latest");
});
it("returns 'stable' when env var is 'stable'", () => {
process.env.certd_release_mode = "stable";
assert.equal(getReleaseMode(), "stable");
});
it("returns 'latest' when env var is an unknown value", () => {
process.env.certd_release_mode = "unknown";
assert.equal(getReleaseMode(), "latest");
});
after(() => {
delete process.env.certd_release_mode;
});
});
@@ -1,4 +1,4 @@
import { Body, Controller, Get, Inject, Post, Provide } from "@midwayjs/core";
import { Body, Config, Controller, Get, Inject, Post, Provide } from "@midwayjs/core";
import { BaseController, Constants, FileService, SysSettingsService, SysSiteInfo } from "@certd/lib-server";
import { http, logger } from "@certd/basic";
import { isComm } from "@certd/plus-core";
@@ -8,10 +8,6 @@ export function normalizeReleaseVersion(release: { tag_name?: string; name?: str
return version.replace(/^v/i, "");
}
export function getReleaseMode(): "stable" | "latest" {
return process.env.certd_release_mode === "stable" ? "stable" : "latest";
}
/**
*/
@Provide()
@@ -22,9 +18,14 @@ export class AppController extends BaseController {
@Inject()
fileService: FileService;
@Config("release.mode")
releaseMode: "stable" | "latest";
config: any;
@Get("/latest", { description: Constants.per.authOnly })
async latest(): Promise<any> {
const mode = getReleaseMode();
const mode = this.releaseMode;
try {
let latest = "";
if (mode === "stable") {
@@ -4,8 +4,7 @@ import { AppKey, getPlusInfo, isComm } from "@certd/plus-core";
import { SysInviteCommissionSetting } from "@certd/commercial-core";
import { cloneDeep } from "lodash-es";
import { getVersion } from "../../utils/version.js";
import { http } from "@certd/basic";
import { getReleaseMode } from "./app-controller.js";
import { http, logger } from "@certd/basic";
/**
*/
@@ -17,6 +16,9 @@ export class BasicSettingsController extends BaseController {
@Config("account.server.baseUrl")
accountServerBaseUrl: any;
@Config("release.mode")
releaseMode: "stable" | "latest";
@Config("agent")
agentConfig: SysSiteEnv["agent"];
@@ -97,6 +99,7 @@ export class BasicSettingsController extends BaseController {
const suiteSetting = await this.getSuiteSetting();
const inviteSetting = await this.getInviteSetting();
const version = await getVersion();
logger.info(`当前版本模式:${this.releaseMode}, 当前版本:${version}`);
return this.ok({
sysPublic,
installInfo,
@@ -109,7 +112,7 @@ export class BasicSettingsController extends BaseController {
app: {
time: new Date().getTime(),
version,
releaseMode: getReleaseMode(),
releaseMode: this.releaseMode,
},
});
}
@@ -0,0 +1,33 @@
import { ALL, Body, Controller, Inject, Post, Provide } from "@midwayjs/core";
import { CodeException, Constants } from "@certd/lib-server";
import { isPlus } from "@certd/plus-core";
import { ApiTags } from "@midwayjs/swagger";
import { OpenKey } from "../../../modules/open/service/open-key-service.js";
import { BaseOpenController } from "../base-open-controller.js";
import { ClientHeartbeatReq, ClientService } from "../../../modules/client/service/client-service.js";
/**
* certd-client 客户端上报接口。
*/
@Provide()
@Controller("/api/v1/client")
@ApiTags(["openapi"])
export class OpenClientController extends BaseOpenController {
@Inject()
clientService: ClientService;
@Post("/heartbeat", { description: Constants.per.open, summary: "客户端心跳上报" })
async heartbeat(@Body(ALL) bean: ClientHeartbeatReq) {
const openKey: OpenKey = this.ctx.openKey;
const userId = openKey.userId;
if (userId == null) {
throw new CodeException(Constants.res.openKeyError);
}
// 客户端上报仅专业版可用;非专业版直接忽略,不保存也不报错。
if (!isPlus()) {
return this.ok({ success: true });
}
await this.clientService.heartbeat(userId, openKey.projectId, openKey.keyId, bean);
return this.ok({ success: true });
}
}
@@ -0,0 +1,47 @@
import { ALL, Body, Controller, Inject, Post, Provide, Query } from "@midwayjs/core";
import { Constants, CrudController } from "@certd/lib-server";
import { ApiTags } from "@midwayjs/swagger";
import { ClientService } from "../../../modules/client/service/client-service.js";
/**
* 客户端管理接口。
*/
@Provide()
@Controller("/api/client")
@ApiTags(["client"])
export class ClientController extends CrudController<ClientService> {
@Inject()
service: ClientService;
getService(): ClientService {
return this.service;
}
@Post("/page", { description: Constants.per.authOnly, summary: "查询客户端分页列表" })
async page(@Body(ALL) body: any) {
body.query = body.query ?? {};
const { projectId, userId } = await this.getProjectUserIdRead();
body.query.projectId = projectId;
body.query.userId = userId;
const res = await this.service.page({
query: body.query,
page: body.page,
sort: body.sort,
});
return this.ok(res);
}
@Post("/info", { description: Constants.per.authOnly, summary: "查询客户端详情" })
async info(@Query("id") id: number) {
await this.checkOwner(this.getService(), id, "read");
const bean = await this.service.info(id);
return this.ok(bean);
}
@Post("/delete", { description: Constants.per.authOnly, summary: "删除客户端记录" })
async delete(@Query("id") id: number) {
await this.checkOwner(this.getService(), id, "write");
const res = await super.delete(id);
return res;
}
}
@@ -134,6 +134,14 @@ export class SiteInfoController extends CrudController<SiteInfoService> {
return this.ok();
}
@Post("/checkBatch", { description: Constants.per.authOnly, summary: "批量检查站点监控" })
async checkBatch(@Body(ALL) body: any) {
const { projectId, userId } = await this.getProjectUserIdRead();
const count = await this.service.checkBatch(body.ids, userId, projectId);
this.auditLog({ content: `批量检查了${count}条站点监控` });
return this.ok({ count });
}
@Post("/checkAll", { description: Constants.per.authOnly, summary: "检查所有站点监控" })
async checkAll() {
const { projectId, userId } = await this.getProjectUserIdWrite();
@@ -62,7 +62,7 @@ export class OpenKeyController extends CrudController<OpenKeyService> {
body.userId = userId;
const res = await this.service.add(body);
this.auditLog({
content: `新增了API密钥(ID:${res.id}, scope:${body.scope})`,
content: `新增了API密钥(ID:${res.id}, scope:${body.scope}, remark:${body.remark})`,
});
return this.ok(res);
}
@@ -72,9 +72,14 @@ export class OpenKeyController extends CrudController<OpenKeyService> {
await this.checkOwner(this.getService(), bean.id, "write");
delete bean.userId;
delete bean.projectId;
await this.service.update(bean);
const upd = {
id: bean.id,
remark: bean.remark,
scope: bean.scope,
};
await this.service.update(upd);
this.auditLog({
content: `修改了API密钥(ID:${bean.id})`,
content: `修改了API密钥(ID:${bean.id}, scope:${bean.scope}, remark:${bean.remark})`,
});
return this.ok();
}
@@ -100,9 +105,10 @@ export class OpenKeyController extends CrudController<OpenKeyService> {
@Post("/delete", { description: Constants.per.authOnly, summary: "删除开放API密钥" })
async delete(@Query("id") id: number) {
await this.checkOwner(this.getService(), id, "write");
const info = await this.service.info(id);
const res = await super.delete(id);
this.auditLog({
content: `删除了API密钥(ID:${id})`,
content: `删除了API密钥(ID:${id}, scope:${info.scope}, remark:${info.remark})`,
});
return res;
}
@@ -1,7 +1,16 @@
import assert from "assert";
import { getDnsPersistIssuer } from "@certd/acme-client";
import { buildDnsPersistRecordValue, DnsPersistRecordService } from "./dns-persist-record-service.js";
describe("DnsPersistRecordService", () => {
it("gets issuer from the ACME directory URL", () => {
assert.equal(getDnsPersistIssuer("https://dv.acme-v02.api.pki.goog/directory"), "pki.goog");
assert.equal(getDnsPersistIssuer("https://acme-v02.api.letsencrypt.org/directory"), "letsencrypt.org");
assert.equal(getDnsPersistIssuer("https://acme.example.co.uk/directory"), "example.co.uk");
assert.equal(getDnsPersistIssuer(), "letsencrypt.org");
assert.throws(() => getDnsPersistIssuer("not a url"), /Directory URL无效/);
assert.throws(() => getDnsPersistIssuer("https://localhost/directory"), /可注册主域名/);
});
it("builds dns-persist-01 record value", () => {
const value = buildDnsPersistRecordValue({
accountUri: "https://example.com/acct/1",
@@ -12,6 +21,36 @@ describe("DnsPersistRecordService", () => {
assert.equal(value, "letsencrypt.org; accounturi=https://example.com/acct/1; policy=wildcard; persistUntil=1893456000");
});
it("uses the registrable domain from the ACME directory URL", async () => {
const service = new DnsPersistRecordService();
(service as any).accessService = {
async getAccessById() {
return {
account: JSON.stringify({
accountKey: "private-key",
accountUri: "https://pki.goog/acct/12345678",
caType: "custom",
directoryUrl: "https://dv.acme-v02.api.pki.goog/directory",
}),
};
},
};
const buildRequest = {
domain: "example.com",
caType: "custom",
acmeAccountAccessId: 12,
userId: 1,
};
const acmeAccount = await service.getAcmeAccount(buildRequest);
assert.equal(acmeAccount.account.issuer, "pki.goog");
const record = await service.buildRecordByAcmeAccount(buildRequest);
assert.equal(record.recordValue, "pki.goog; accounturi=https://pki.goog/acct/12345678; policy=wildcard");
});
it("builds validation host from wildcard domain", async () => {
const service = new DnsPersistRecordService();
@@ -68,7 +107,7 @@ describe("DnsPersistRecordService", () => {
assert.equal(record.accountUri, "https://example.com/acct/1");
assert.equal(record.hostRecord, "_validation-persist");
assert.equal(record.mainDomain, "example.com");
assert.equal(record.recordValue, "letsencrypt.org; accounturi=https://example.com/acct/1; policy=wildcard");
assert.equal(record.recordValue, "example.com; accounturi=https://example.com/acct/1; policy=wildcard");
assert.equal(record.policy, "wildcard");
assert.equal(record.status, "pending");
});
@@ -140,7 +179,7 @@ describe("DnsPersistRecordService", () => {
assert.equal(record.id, 77);
assert.equal(record.hostRecord, "_validation-persist");
assert.equal(record.mainDomain, "example.com");
assert.equal(record.recordValue, "letsencrypt.org; accounturi=https://example.com/acct/1; policy=wildcard");
assert.equal(record.recordValue, "example.com; accounturi=https://example.com/acct/1; policy=wildcard");
assert.equal(record.status, "pending");
});
@@ -245,7 +284,7 @@ describe("DnsPersistRecordService", () => {
assert.equal(record.policy, "wildcard");
assert.equal(record.status, "pending");
assert.equal(record.mainDomain, "example.com");
assert.equal(record.recordValue, "letsencrypt.org; accounturi=https://example.com/acct/1; policy=wildcard");
assert.equal(record.recordValue, "example.com; accounturi=https://example.com/acct/1; policy=wildcard");
assert.equal(record.recordRes, null);
});
@@ -2,14 +2,13 @@ import { BaseService } from "@certd/lib-server";
import { Inject, Provide, Scope, ScopeEnum } from "@midwayjs/core";
import { InjectEntityModel } from "@midwayjs/typeorm";
import { In, Repository } from "typeorm";
import { createChallengeFn } from "@certd/acme-client";
import { createChallengeFn, getDnsPersistIssuer } from "@certd/acme-client";
import { AccessService } from "@certd/lib-server";
import { http, logger, utils } from "@certd/basic";
import { createDnsProvider, DomainParser } from "@certd/plugin-lib";
import { DnsPersistRecordEntity } from "../entity/dns-persist-record.js";
import { TaskServiceBuilder } from "../../pipeline/service/getter/task-service-getter.js";
import { DomainEntity } from "../entity/domain.js";
export function buildDnsPersistRecordValue(req: { issuer?: string; accountUri: string; wildcard?: boolean; persistUntil?: number }) {
const parts = [req.issuer || "letsencrypt.org", `accounturi=${req.accountUri}`];
if (req.wildcard !== false) {
@@ -93,8 +92,12 @@ export class DnsPersistRecordService extends BaseService<DnsPersistRecordEntity>
if (!parsed.accountKey || !parsed.accountUri) {
throw new Error("ACME账号授权无效,请重新生成ACME账号");
}
if (!parsed.issuer) {
parsed.issuer = getDnsPersistIssuer(parsed.directoryUrl || parsed.accountUri);
}
return parsed;
}
async getAcmeAccount(req: DnsPersistRecordBuildReq & { userId: number; projectId?: number }) {
const accessId = req.acmeAccountAccessId || req.commonAcmeAccountAccessId;
@@ -123,7 +126,7 @@ export class DnsPersistRecordService extends BaseService<DnsPersistRecordEntity>
};
}
async buildRecord(req: { domain: string; accountUri: string; wildcard?: boolean; persistUntil?: number; userId?: number; projectId?: number }) {
async buildRecord(req: { domain: string; accountUri: string; issuer?: string; wildcard?: boolean; persistUntil?: number; userId?: number; projectId?: number }) {
const domain = this.normalizeDomain(req.domain);
const mainDomain = await this.parseMainDomain(domain, req.userId, req.projectId);
return {
@@ -141,6 +144,7 @@ export class DnsPersistRecordService extends BaseService<DnsPersistRecordEntity>
const record = await this.buildRecord({
domain: req.domain,
accountUri: account.accountUri,
issuer: account.issuer,
wildcard: true,
persistUntil: req.persistUntil,
userId: req.userId,
@@ -0,0 +1,63 @@
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
/**
* certd-client 客户端登记信息。
* 客户端自身信息(机器名、版本、系统、关联OpenKey)保留独立列;
* 站点统计等快照统一存 content JSON,后续扩展直接往 JSON 增加字段,无需变更表结构。
*/
@Entity("cd_client")
export class ClientEntity {
@PrimaryGeneratedColumn()
id: number;
@Column({ name: "user_id", comment: "用户id" })
userId: number;
@Column({ name: "project_id", comment: "项目id", nullable: true })
projectId: number;
@Column({ name: "client_id", comment: "客户端唯一标识", length: 64 })
clientId: string;
@Column({ name: "key_id", comment: "关联OpenKey", length: 64, nullable: true })
keyId: string;
@Column({ name: "machine_name", comment: "本机名称", length: 255, nullable: true })
machineName: string;
@Column({ name: "version", comment: "客户端版本", length: 64, nullable: true })
version: string;
@Column({ name: "os", comment: "操作系统", length: 64, nullable: true })
os: string;
@Column({ name: "content", comment: "站点统计等快照(JSON)", type: "text", nullable: true })
content: string;
@Column({ name: "last_heartbeat_at", comment: "上次心跳时间" })
lastHeartbeatAt: number;
@Column({
name: "create_time",
comment: "创建时间",
default: () => "CURRENT_TIMESTAMP",
})
createTime: Date;
@Column({
name: "update_time",
comment: "修改时间",
default: () => "CURRENT_TIMESTAMP",
})
updateTime: Date;
// 以下均为非数据库字段:分页查询时从 content 解析展开,便于前端直接展示。
appCount?: number;
siteCount?: number;
httpsSiteCount?: number;
syncedSiteCount?: number;
failedSiteCount?: number;
lastSyncAt?: number;
lastSyncStatus?: string;
online?: boolean;
}
@@ -0,0 +1,2 @@
export * from "./entity/client.js";
export * from "./service/client-service.js";
@@ -0,0 +1,120 @@
import assert from "assert";
import { ClientService, isOnline } from "./client-service.js";
function createService() {
return new ClientService();
}
describe("ClientService", () => {
describe("isOnline", () => {
it("30分钟内判定在线", () => {
const now = Date.now();
assert.equal(isOnline(now - 29 * 60 * 1000, now), true);
assert.equal(isOnline(now - 30 * 60 * 1000, now), true);
});
it("超过30分钟判定离线", () => {
const now = Date.now();
assert.equal(isOnline(now - 30 * 60 * 1000 - 1, now), false);
});
it("从未心跳判定离线", () => {
const now = Date.now();
assert.equal(isOnline(null, now), false);
assert.equal(isOnline(undefined, now), false);
});
});
describe("heartbeat", () => {
it("首次心跳创建记录,统计信息打包进 content JSON", async () => {
const service = createService();
service.repository = {
async findOne() {
return null;
},
} as any;
let saved: any = null;
service.repository.save = async (entity: any) => {
saved = entity;
entity.id = 1;
return entity;
};
const id = await service.heartbeat(2, 3, "key-1", {
clientId: "client-a",
machineName: "web-01",
version: "0.3.0",
os: "linux",
appCount: 2,
siteCount: 10,
httpsSiteCount: 8,
syncedSiteCount: 5,
failedSiteCount: 1,
});
assert.equal(id, 1);
assert.equal(saved.userId, 2);
assert.equal(saved.projectId, 3);
assert.equal(saved.clientId, "client-a");
assert.equal(saved.keyId, "key-1");
assert.equal(saved.machineName, "web-01");
assert.equal(saved.version, "0.3.0");
assert.equal(saved.os, "linux");
assert.ok(saved.lastHeartbeatAt > 0);
const snapshot = JSON.parse(saved.content);
assert.equal(snapshot.appCount, 2);
assert.equal(snapshot.siteCount, 10);
assert.equal(snapshot.httpsSiteCount, 8);
assert.equal(snapshot.syncedSiteCount, 5);
assert.equal(snapshot.failedSiteCount, 1);
});
it("已有记录时更新,不重复创建", async () => {
const service = createService();
const existing = { id: 9 };
service.repository = {
async findOne() {
return existing;
},
} as any;
let updatedWhere: any = null;
let updatedSet: any = null;
service.repository.update = async (where: any, set: any) => {
updatedWhere = where;
updatedSet = set;
return {} as any;
};
const id = await service.heartbeat(2, undefined, "key-2", {
clientId: "client-a",
machineName: "web-01",
version: "0.3.1",
os: "windows",
siteCount: 3,
});
assert.equal(id, 9);
assert.equal(updatedWhere.id, 9);
assert.equal(updatedSet.machineName, "web-01");
assert.equal(updatedSet.version, "0.3.1");
assert.equal(updatedSet.os, "windows");
assert.ok(updatedSet.lastHeartbeatAt > 0);
const snapshot = JSON.parse(updatedSet.content);
assert.equal(snapshot.siteCount, 3);
// 未传的统计字段默认 0
assert.equal(snapshot.appCount, 0);
assert.equal(snapshot.httpsSiteCount, 0);
});
it("clientId 为空时抛错", async () => {
const service = createService();
service.repository = {
async findOne() {
return null;
},
} as any;
await assert.rejects(() => service.heartbeat(2, undefined, "key", {} as any), /clientId不能为空/);
});
});
});
@@ -0,0 +1,126 @@
import { Provide, Scope, ScopeEnum } from "@midwayjs/core";
import { BaseService, PageReq } from "@certd/lib-server";
import { InjectEntityModel } from "@midwayjs/typeorm";
import { Repository } from "typeorm";
import { ClientEntity } from "../entity/client.js";
export type ClientHeartbeatReq = {
clientId: string;
machineName?: string;
version?: string;
os?: string;
appCount?: number;
siteCount?: number;
httpsSiteCount?: number;
syncedSiteCount?: number;
failedSiteCount?: number;
lastSyncAt?: number;
lastSyncStatus?: string;
};
export type ClientSnapshot = {
appCount?: number;
siteCount?: number;
httpsSiteCount?: number;
syncedSiteCount?: number;
failedSiteCount?: number;
lastSyncAt?: number;
lastSyncStatus?: string;
};
// 客户端心跳上报间隔为 10 分钟,在线阈值取 30 分钟(3 倍),容忍网络抖动与偶发失败。
const ONLINE_THRESHOLD_MS = 30 * 60 * 1000;
export function isOnline(lastHeartbeatAt: number | null | undefined, now: number): boolean {
return lastHeartbeatAt != null && now - lastHeartbeatAt <= ONLINE_THRESHOLD_MS;
}
@Provide()
@Scope(ScopeEnum.Request, { allowDowngrade: true })
export class ClientService extends BaseService<ClientEntity> {
@InjectEntityModel(ClientEntity)
repository: Repository<ClientEntity>;
//@ts-ignore
getRepository() {
return this.repository;
}
async page(pageReq: PageReq<ClientEntity>) {
const pageRet = await super.page(pageReq);
const now = Date.now();
for (const item of pageRet.records) {
const snapshot = this.parseContent(item.content);
item.appCount = snapshot.appCount ?? 0;
item.siteCount = snapshot.siteCount ?? 0;
item.httpsSiteCount = snapshot.httpsSiteCount ?? 0;
item.syncedSiteCount = snapshot.syncedSiteCount ?? 0;
item.failedSiteCount = snapshot.failedSiteCount ?? 0;
item.lastSyncAt = snapshot.lastSyncAt;
item.lastSyncStatus = snapshot.lastSyncStatus;
item.online = isOnline(item.lastHeartbeatAt, now);
}
return pageRet;
}
async heartbeat(userId: number, projectId: number | undefined, keyId: string, bean: ClientHeartbeatReq) {
if (!bean?.clientId) {
throw new Error("clientId不能为空");
}
const content = JSON.stringify({
appCount: bean.appCount ?? 0,
siteCount: bean.siteCount ?? 0,
httpsSiteCount: bean.httpsSiteCount ?? 0,
syncedSiteCount: bean.syncedSiteCount ?? 0,
failedSiteCount: bean.failedSiteCount ?? 0,
lastSyncAt: bean.lastSyncAt,
lastSyncStatus: bean.lastSyncStatus,
});
const now = Date.now();
const existing = await this.repository.findOne({
where: { userId, clientId: bean.clientId },
});
if (existing) {
await this.repository.update(
{ id: existing.id },
{
projectId,
keyId,
machineName: bean.machineName,
version: bean.version,
os: bean.os,
content,
lastHeartbeatAt: now,
updateTime: new Date(),
}
);
return existing.id;
}
const entity = new ClientEntity();
entity.userId = userId;
entity.projectId = projectId;
entity.clientId = bean.clientId;
entity.keyId = keyId;
entity.machineName = bean.machineName;
entity.version = bean.version;
entity.os = bean.os;
entity.content = content;
entity.lastHeartbeatAt = now;
entity.createTime = new Date();
entity.updateTime = new Date();
await this.repository.save(entity);
return entity.id;
}
private parseContent(content: string): ClientSnapshot {
if (!content) {
return {};
}
try {
return JSON.parse(content) as ClientSnapshot;
} catch (err) {
return {};
}
}
}
@@ -0,0 +1,101 @@
import assert from "node:assert/strict";
import { SiteInfoService } from "./site-info-service.js";
/**
* 批量导入的站点(doImport → add)必须带上明确的 ipCheck/ipSyncAuto/ipIgnoreCoherence 默认值。
* 否则数据库里这三列为 NULL,编辑弹窗的“开启IP检查”开关看着是关闭状态,
* 但必填校验认为没有值,用户必须手动开关一次才能保存(issue #803)。
*/
function createServiceForAdd() {
const service = new SiteInfoService();
service.checkMonitorLimit = async () => {};
service.repository = {
async findOne() {
return null;
},
async save(data: any) {
return data;
},
async create(data: any) {
return data;
},
} as any;
return service;
}
describe("SiteInfoService", () => {
describe("add 站点默认值(issue #803)", () => {
it("新增站点未传 ipCheck 时,落库默认值为 false(而不是 undefined/null)", async () => {
const service = createServiceForAdd();
let savedData: any = null;
service.repository.save = async (data: any) => {
savedData = data;
return data;
};
await service.add({
domain: "a.com",
name: "a.com",
httpsPort: 443,
userId: 1,
} as any);
assert.equal(savedData.ipCheck, false, "ipCheck 必须有明确的布尔默认值 false");
assert.equal(savedData.ipSyncAuto, true, "ipSyncAuto 必须有明确的布尔默认值 true");
assert.equal(savedData.ipIgnoreCoherence, false, "ipIgnoreCoherence 必须有明确的布尔默认值 false");
});
it("显式传入的 ipCheck 不被默认值覆盖", async () => {
const service = createServiceForAdd();
let savedData: any = null;
service.repository.save = async (data: any) => {
savedData = data;
return data;
};
await service.add({
domain: "b.com",
name: "b.com",
httpsPort: 443,
userId: 1,
ipCheck: true,
ipSyncAuto: false,
ipIgnoreCoherence: true,
} as any);
assert.equal(savedData.ipCheck, true);
assert.equal(savedData.ipSyncAuto, false);
assert.equal(savedData.ipIgnoreCoherence, true);
});
});
describe("doImport 批量导入(issue #803)", () => {
it("导入的每个站点落库都带上 ipCheck 默认值 false,且字段解析正确", async () => {
const service = createServiceForAdd();
const savedList: any[] = [];
service.repository.save = async (data: any) => {
savedList.push(data);
return data;
};
await service.doImport({
text: "a.com\nb.com:8443:c.com:备注",
userId: 1,
projectId: 2,
});
// add() 里已有站点时会 return {id},此处 findOne 返回 null,两条都会真实落库
assert.equal(savedList.length, 2);
for (const item of savedList) {
assert.equal(item.ipCheck, false, `站点 ${item.domain} 必须带 ipCheck 默认值`);
}
assert.equal(savedList[0].domain, "a.com");
assert.equal(savedList[0].httpsPort, 443);
assert.equal(savedList[0].name, "a.com");
assert.equal(savedList[1].domain, "b.com");
assert.equal(savedList[1].httpsPort, 8443);
assert.equal(savedList[1].name, "c.com");
assert.equal(savedList[1].remark, "备注");
});
});
});
@@ -99,6 +99,18 @@ export class SiteInfoService extends BaseService<SiteInfoEntity> {
data.disabled = false;
// 批量导入等入口不会传这几个IP检查字段,必须补上明确的布尔默认值,
// 否则数据库里存 NULL,编辑弹窗的开关看着是关闭状态却过不了必填校验(issue #803)
if (data.ipCheck == null) {
data.ipCheck = false;
}
if (data.ipSyncAuto == null) {
data.ipSyncAuto = true;
}
if (data.ipIgnoreCoherence == null) {
data.ipIgnoreCoherence = false;
}
const found = await this.repository.findOne({
where: {
domain: data.domain,
@@ -312,6 +324,23 @@ export class SiteInfoService extends BaseService<SiteInfoEntity> {
return;
}
async checkBatch(ids: number[], userId: number, projectId?: number) {
const validIds = this.filterIds(ids);
if (validIds.length === 0) {
return 0;
}
const userProjectQuery = this.buildUserProjectQuery(userId, projectId);
const sites = await this.repository.find({
where: {
id: In(validIds),
...userProjectQuery,
disabled: false,
},
});
await this.checkList(sites);
return sites.length;
}
async sendCheckErrorNotify(siteId: number, fromIpCheck = false, setting: UserSiteMonitorSetting) {
const site = await this.info(siteId);
const url = await this.notificationService.getBindUrl("#/certd/monitor/site");
@@ -0,0 +1,17 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { getMetadataArgsStorage } from "typeorm";
import { OpenKeyEntity } from "./open-key.js";
describe("OpenKeyEntity", () => {
it("应映射密钥用途备注字段", () => {
const remarkColumn = getMetadataArgsStorage().columns.find(column => {
return column.target === OpenKeyEntity && column.propertyName === "remark";
});
assert.ok(remarkColumn);
assert.equal(remarkColumn.options.name, "remark");
assert.equal(remarkColumn.options.length, 512);
assert.equal(remarkColumn.options.nullable, true);
});
});
@@ -17,6 +17,9 @@ export class OpenKeyEntity {
@Column({ name: "scope", comment: "权限范围" })
scope: string; // open 仅开放接口、 user 用户所有权限
@Column({ name: "remark", comment: "密钥用途备注", length: 512, nullable: true })
remark: string; // 用于说明该密钥的用途,便于区分和查找
@Column({ name: "project_id", comment: "项目id" })
projectId: number;
@@ -31,10 +31,11 @@ export class OpenKeyService extends BaseService<OpenKeyEntity> {
}
async add(bean: OpenKeyEntity) {
return await this.generate(bean.userId, bean.projectId, bean.scope);
return await this.generate({ userId: bean.userId, projectId: bean.projectId, scope: bean.scope, remark: bean.remark });
}
async generate(userId: number, projectId?: number, scope = "open") {
async generate(req: { userId: number; projectId?: number; scope?: string; remark?: string }) {
const { userId, projectId, scope, remark } = req;
const keyId = utils.id.simpleNanoId(18) + "_key";
const secretKey = crypto.randomBytes(32);
const keySecret = Buffer.from(secretKey).toString("hex");
@@ -44,6 +45,7 @@ export class OpenKeyService extends BaseService<OpenKeyEntity> {
entity.keyId = keyId;
entity.keySecret = keySecret;
entity.scope = scope ?? "open";
entity.remark = remark;
await this.repository.save(entity);
return entity;
}

Some files were not shown because too many files have changed in this diff Show More