Compare commits

...

108 Commits

Author SHA1 Message Date
xiaojunnuo
c49ccbde93 v1.24.2 2024-09-06 22:34:49 +08:00
xiaojunnuo
fc73d9d615 build: prepare to build 2024-09-06 22:33:30 +08:00
xiaojunnuo
1133d6b0f7 chore: 2024-09-06 22:32:29 +08:00
xiaojunnuo
b80210f24b perf: 优化跳过处理逻辑 2024-09-06 10:19:03 +08:00
xiaojunnuo
3bad0b2685 chore: 1 2024-09-06 00:13:21 +08:00
xiaojunnuo
af388ec39f Merge remote-tracking branch 'origin/v2' into v2 2024-09-05 18:01:04 +08:00
xiaojunnuo
8d7c2c8e29 Merge branch 'v2' of https://github.com/certd/certd into v2 2024-09-05 18:01:05 +08:00
xiaojunnuo
8088cd6d58 1 2024-09-05 18:00:51 +08:00
xiaojunnuo
590ce9642e 1 2024-09-05 18:00:45 +08:00
xiaojunnuo
99302b8ff2 chore: 2024-09-05 16:19:00 +08:00
xiaojunnuo
14b108f09e chore: 2024-09-05 16:18:42 +08:00
xiaojunnuo
0669835d4e chore: 2024-09-05 16:11:03 +08:00
xiaojunnuo
fbeaed2035 perf: 支持pfx、der 2024-09-05 15:36:35 +08:00
xiaojunnuo
ecad7f58c1 chore: 2024-09-05 14:33:45 +08:00
xiaojunnuo
1dd9a8d4d3 docs: 2024-09-05 11:00:21 +08:00
xiaojunnuo
bd73a163cd perf: 阶段、任务、步骤全面支持拖动排序 2024-09-05 10:47:03 +08:00
xiaojunnuo
1e9b5638aa perf: 任务支持拖动排序 2024-09-05 01:39:46 +08:00
xiaojunnuo
71ac8aae4a fix: 修复windows下无法执行第二条命令的bug 2024-09-05 00:04:31 +08:00
xiaojunnuo
d5bfcdb6de perf: 修复windows下无法执行第二条命令的bug 2024-09-04 18:29:39 +08:00
xiaojunnuo
1480efb43d pref: 支持https启动 2024-09-04 16:15:42 +08:00
xiaojunnuo
1c17b41e16 perf: 西部数据支持用户级的apikey 2024-09-04 15:49:15 +08:00
xiaojunnuo
192d9dc7e3 perf: 任务配置不需要的字段可以自动隐藏 2024-09-04 15:49:00 +08:00
xiaojunnuo
d0d3c2b588 Merge remote-tracking branch 'origin/v2' into v2 2024-09-04 11:28:22 +08:00
Greper
b8a8f20448 perf: 支持西部数码DNS
perf: 支持西部数码DNS
2024-09-04 11:28:07 +08:00
xiaojunnuo
28a32aed7d chore: 2024-09-04 11:26:56 +08:00
xiaojunnuo
ff46771d8d perf: client 请求超时时间延长为10s 2024-09-03 22:09:48 +08:00
xiaojunnuo
87a2673e8c perf: 支持阿里云oss 2024-09-03 18:21:02 +08:00
Moeyuuko
c59cab1aae perf: 支持西部数码DNS 2024-09-03 15:40:45 +08:00
xiaojunnuo
6314e8d7eb fix: 修复复制流水线出现的各种问题 2024-09-03 11:42:05 +08:00
xiaojunnuo
5ade12d700 chore: 2024-09-03 00:26:35 +08:00
xiaojunnuo
ceb210b1b7 chore: 2024-09-03 00:06:13 +08:00
xiaojunnuo
5e084db038 build: trigger build image 2024-09-02 23:58:23 +08:00
xiaojunnuo
bef6b981e2 v1.24.1 2024-09-02 23:57:42 +08:00
xiaojunnuo
a77cd65789 build: prepare to build 2024-09-02 23:56:04 +08:00
xiaojunnuo
415b731d9a chore: 2024-09-02 23:55:38 +08:00
xiaojunnuo
6c0099d600 chore: 2024-09-02 23:47:15 +08:00
xiaojunnuo
98b77f8084 perf: 支持阿里云 DCDN 2024-09-02 23:46:28 +08:00
Greper
2f47ffb76b Merge pull request #148 from wujingke/v2
pref: 添加阿里云DCDN 废弃SetDomainServerCertificate接口 改为SetCdnDomainSSLCertificate
2024-09-02 22:00:36 +08:00
w
35a3603c41 添加阿里云DCDN 废弃SetDomainServerCertificate接口 改为SetCdnDomainSSLCertificate 2024-09-02 19:33:17 +08:00
xiaojunnuo
ea775adae1 perf: 支持已跳过的步骤重新运行 2024-09-02 18:36:12 +08:00
xiaojunnuo
724a85028b perf: 支持cdnfly 2024-09-02 16:59:49 +08:00
xiaojunnuo
b2d595e85c Merge remote-tracking branch 'origin/v2' into v2 2024-09-02 15:50:43 +08:00
xiaojunnuo
d9b1ff8c5c chore: 2024-09-02 15:49:56 +08:00
xiaojunnuo
1c17970b98 fix: 激活仅限管理员 2024-09-02 01:02:41 +08:00
xiaojunnuo
b9bddbfabb perf: 支持ftp上传 2024-09-01 04:49:26 +08:00
xiaojunnuo
ee617095ef perf: 部署插件支持宝塔、易盾云等 2024-08-30 18:52:31 +08:00
xiaojunnuo
bee20c7f51 chore: 1 2024-08-29 11:15:45 +08:00
xiaojunnuo
b8e05e9b44 chore: 2024-08-29 10:09:22 +08:00
xiaojunnuo
869e14bad9 pref: 自动优化数据库,释放被删除空间 2024-08-29 09:57:27 +08:00
xiaojunnuo
952e01ab7d chore: 2024-08-28 14:45:57 +08:00
xiaojunnuo
db61033633 perf: 优化内存占用 2024-08-28 14:40:50 +08:00
xiaojunnuo
42a56b581d perf: 授权配置支持加密
原本已经添加的授权配置,再次编辑保存即变成加密配置
2024-08-27 13:46:19 +08:00
xiaojunnuo
d6bb9f6af4 chore: 2024-08-26 12:37:42 +08:00
xiaojunnuo
a430b27034 chore: 2024-08-26 12:32:36 +08:00
xiaojunnuo
0f6679425f fix: 修复在没有勾选使用代理的情况下,仍然会使用代理的bug 2024-08-26 11:34:01 +08:00
xiaojunnuo
4b9d1eb4b5 chore: 2024-08-26 11:06:46 +08:00
xiaojunnuo
ca4a1b8d92 chore: 2024-08-26 11:06:28 +08:00
xiaojunnuo
08a702a758 chore: 2024-08-26 10:19:06 +08:00
xiaojunnuo
589191244f chore: 2024-08-26 10:09:53 +08:00
xiaojunnuo
f3ddcd3054 Merge remote-tracking branch 'origin/v2' into v2 2024-08-26 09:59:10 +08:00
xiaojunnuo
f923655d91 chore: 2024-08-26 09:58:51 +08:00
xiaojunnuo
879e2609ca chore: 2024-08-25 15:35:04 +08:00
xiaojunnuo
d227dd64e3 chore: 2024-08-25 15:34:00 +08:00
xiaojunnuo
d2997624b0 build: trigger build image 2024-08-25 14:28:10 +08:00
xiaojunnuo
f17b08ddab v1.24.0 2024-08-25 14:27:41 +08:00
xiaojunnuo
893b853fd4 build: prepare to build 2024-08-25 14:26:34 +08:00
xiaojunnuo
15846eda85 chore: 2024-08-25 12:07:47 +08:00
xiaojunnuo
19ddf61127 chore: 2024-08-25 11:57:07 +08:00
xiaojunnuo
a7424e02f5 feat: 支持ECC类型 2024-08-25 11:56:15 +08:00
xiaojunnuo
d4092e4929 chore: 1 2024-08-25 03:27:38 +08:00
xiaojunnuo
62ef54c7c3 chore: 1 2024-08-25 03:14:07 +08:00
xiaojunnuo
85ae80c882 Merge remote-tracking branch 'origin/acme_sync' into v2
# Conflicts:
#	packages/core/acme-client/package.json
2024-08-25 03:01:13 +08:00
xiaojunnuo
a593056e79 feat: 支持google证书申请(需要使用代理) 2024-08-25 02:59:49 +08:00
xiaojunnuo
22a336370a fix: 修复执行日志没有清理的bug 2024-08-25 01:55:34 +08:00
xiaojunnuo
86ebbcb9bb chore: 1 2024-08-24 23:48:26 +08:00
xiaojunnuo
c28f3cdcf7 chore: 1 2024-08-24 01:05:06 +08:00
xiaojunnuo
41b9837582 fix: 修复ssh无法连接成功,无法执行命令的bug 2024-08-24 00:00:27 +08:00
xiaojunnuo
37eb762afe fix: 修复成功后跳过之后丢失腾讯云证书id的bug 2024-08-23 23:26:31 +08:00
xiaojunnuo
3345c145b8 fix: 部署到腾讯云cdn选择证书任务步骤限制只能选证书 2024-08-23 18:02:14 +08:00
xiaojunnuo
17ead547aa fix: 修复创建流水线后立即运行时报no id错误的bug
Closes https://github.com/certd/certd/issues/135
2024-08-23 17:41:02 +08:00
xiaojunnuo
e358a88696 fix: 修复重置密码参数配置后无效的bug 2024-08-23 17:16:52 +08:00
xiaojunnuo
968c4690a0 perf: 优化证书申请成功率 2024-08-23 13:15:06 +08:00
xiaojunnuo
453f1baa0b perf: email proxy 2024-08-23 11:35:34 +08:00
xiaojunnuo
14ab93dc2f chore: 1 2024-08-21 12:38:09 +08:00
xiaojunnuo
790bf11af0 perf: 优化dnspod的token id 说明 2024-08-21 10:36:10 +08:00
xiaojunnuo
95122e2860 fix: 修复使用代理的情况下申请证书失败的bug 2024-08-21 10:34:50 +08:00
xiaojunnuo
ab7a1673ff chore: plus 2024-08-21 08:36:03 +08:00
xiaojunnuo
db9d27468e chore: license说明 2024-08-14 21:24:12 +08:00
xiaojunnuo
746bb9d385 perf: 更新k8s底层api库 2024-08-14 15:10:55 +08:00
xiaojunnuo
7b451bbf6e perf: 优化成功后跳过的提示 2024-08-13 20:30:42 +08:00
xiaojunnuo
ffc4e71783 build: trigger build image 2024-08-06 11:39:34 +08:00
xiaojunnuo
7eb6d7d053 v1.23.1 2024-08-06 11:39:21 +08:00
xiaojunnuo
93b6431369 build: prepare to build 2024-08-06 11:38:30 +08:00
xiaojunnuo
d301ac6832 chore: 2024-08-06 11:37:46 +08:00
xiaojunnuo
1af19f0ac0 perf: 优化默认值设置 2024-08-06 11:32:25 +08:00
xiaojunnuo
24c7be2c9c perf: 优化插件字段的default value 2024-08-06 11:23:23 +08:00
xiaojunnuo
c3f04a80fd chore: pg 改成bigint 2024-08-06 11:10:33 +08:00
xiaojunnuo
bf6c5d690e chore: 权益 2024-08-06 10:32:07 +08:00
xiaojunnuo
7c92762f48 chore: k8s logger 2024-08-06 10:23:09 +08:00
xiaojunnuo
efacfd6b2c chore: k8s logger 2024-08-06 10:22:28 +08:00
xiaojunnuo
93559174c7 fix: 修复模糊查询无效的bug 2024-08-06 10:12:02 +08:00
xiaojunnuo
1b0ae8654f chore: 2024-08-06 09:12:25 +08:00
xiaojunnuo
d11a19ce59 chore: 2024-08-06 09:12:14 +08:00
xiaojunnuo
9a68b0fb61 chore: 2024-08-06 09:11:31 +08:00
xiaojunnuo
79bbdce1e1 chore: 2024-08-06 09:04:38 +08:00
xiaojunnuo
916ee4a089 chore: 2024-08-06 09:00:45 +08:00
xiaojunnuo
ac9313da38 build: trigger build image 2024-08-05 18:08:50 +08:00
GitHub Actions Bot
e5edfbfa6d 🔱: [acme] sync upgrade with 6 commits [trident-sync]
Bump v5.4.0
Bump dependencies
Retry HTTP requests on server errors or when rate limited
Forgot to refresh directory timestamp after successful get
Add utility method tests
2024-07-16 19:24:08 +00:00
203 changed files with 4627 additions and 2079 deletions

View File

@@ -58,8 +58,14 @@ jobs:
username: ${{ secrets.aliyun_cs_username }}
password: ${{ secrets.aliyun_cs_password }}
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.dockerhub_username }}
password: ${{ secrets.dockerhub_password }}
- name: Build and push
uses: docker/build-push-action@v6.5.0
uses: docker/build-push-action@v6
with:
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true
@@ -67,3 +73,5 @@ jobs:
tags: |
registry.cn-shenzhen.aliyuncs.com/handsfree/certd:latest
registry.cn-shenzhen.aliyuncs.com/handsfree/certd:${{steps.get_certd_version.outputs.result}}
greper/certd:latest
greper/certd:${{steps.get_certd_version.outputs.result}}

12
.gitignore vendored
View File

@@ -19,23 +19,13 @@ gen
/*.log
/packages/ui/*/.idea
/packages/ui/*/node_modules
/packages/*/node_modules
/packages/ui/certd-server/tmp/
/packages/ui/certd-ui/dist/
/other
/dev-sidecar-test
/packages/core/certd/yarn.lock
/packages/test
/test/own
/pnpm-lock.yaml
docker/image/workspace
/packages/core/lego
tsconfig.tsbuildinfo
test/**/*.js
/packages/ui/certd-server/data/db.sqlite
/packages/ui/certd-server/data/keys.yaml
/packages/pro/

View File

@@ -3,6 +3,79 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.24.2](https://github.com/certd/certd/compare/v1.24.1...v1.24.2) (2024-09-06)
### Bug Fixes
* 修复复制流水线出现的各种问题 ([6314e8d](https://github.com/certd/certd/commit/6314e8d7eb58cd52e2a7bd3b5ffb9112b0b69577))
* 修复windows下无法执行第二条命令的bug ([71ac8aa](https://github.com/certd/certd/commit/71ac8aae4aa694e1a23761e9761c9fba30b43a21))
### Performance Improvements
* 阶段、任务、步骤全面支持拖动排序 ([bd73a16](https://github.com/certd/certd/commit/bd73a163cd0497f062bd424ddc6bc9bbc95f81ea))
* 任务配置不需要的字段可以自动隐藏 ([192d9dc](https://github.com/certd/certd/commit/192d9dc7e36737d684c769f255f407c28b1152ac))
* 任务支持拖动排序 ([1e9b563](https://github.com/certd/certd/commit/1e9b5638aa36a8ce70019a9c750230ba41938327))
* 西部数据支持用户级的apikey ([1c17b41](https://github.com/certd/certd/commit/1c17b41e160944b073e1849e6f9467c3659a4bfc))
* 修复windows下无法执行第二条命令的bug ([d5bfcdb](https://github.com/certd/certd/commit/d5bfcdb6de1dcc1702155442e2e00237d0bbb6e5))
* 优化跳过处理逻辑 ([b80210f](https://github.com/certd/certd/commit/b80210f24bf5db1c958d06ab27c9e5d3db452eda))
* 支持阿里云oss ([87a2673](https://github.com/certd/certd/commit/87a2673e8c33dff6eda1b836d92ecc121564ed78))
* 支持西部数码DNS ([c59cab1](https://github.com/certd/certd/commit/c59cab1aaeb19f86df8e3e0d8127cbd0a9ef77f3))
* 支持pfx、der ([fbeaed2](https://github.com/certd/certd/commit/fbeaed203519f59b6d9396c4e8953353ccb5e723))
* client 请求超时时间延长为10s ([ff46771](https://github.com/certd/certd/commit/ff46771d8dd43e71c1ca70e3ba783945750342cc))
## [1.24.1](https://github.com/certd/certd/compare/v1.24.0...v1.24.1) (2024-09-02)
### Bug Fixes
* 激活仅限管理员 ([1c17970](https://github.com/certd/certd/commit/1c17970b981f0987c506744ee6b2283fd5e40493))
* 修复在没有勾选使用代理的情况下仍然会使用代理的bug ([0f66794](https://github.com/certd/certd/commit/0f6679425f6a736bb0128527dd99c085fac17d84))
### Performance Improvements
* 部署插件支持宝塔、易盾云等 ([ee61709](https://github.com/certd/certd/commit/ee617095efa1171548cf52fd45f0f98a368555a3))
* 授权配置支持加密 ([42a56b5](https://github.com/certd/certd/commit/42a56b581d754c3e5f9838179d19ab0d004ef2eb))
* 优化内存占用 ([db61033](https://github.com/certd/certd/commit/db6103363364440b650bc10bb334834e4a9470c7))
* 支持阿里云 DCDN ([98b77f8](https://github.com/certd/certd/commit/98b77f80843834616fb26f83b4c42245326abd06))
* 支持已跳过的步骤重新运行 ([ea775ad](https://github.com/certd/certd/commit/ea775adae18d57a04470cfba6b9460d761d74035))
* 支持cdnfly ([724a850](https://github.com/certd/certd/commit/724a85028b4a7146c9e3b4df4497dcf2a7bf7c67))
* 支持ftp上传 ([b9bddbf](https://github.com/certd/certd/commit/b9bddbfabb5664365f1232e9432532187c98006c))
# [1.24.0](https://github.com/certd/certd/compare/v1.23.1...v1.24.0) (2024-08-25)
### Bug Fixes
* 部署到腾讯云cdn选择证书任务步骤限制只能选证书 ([3345c14](https://github.com/certd/certd/commit/3345c145b802170f75a098a35d0c4b8312efcd17))
* 修复成功后跳过之后丢失腾讯云证书id的bug ([37eb762](https://github.com/certd/certd/commit/37eb762afe25c5896b75dee25f32809f8426e7b7))
* 修复创建流水线后立即运行时报no id错误的bug ([17ead54](https://github.com/certd/certd/commit/17ead547aab25333603980304aa3aad3db1f73d5))
* 修复使用代理的情况下申请证书失败的bug ([95122e2](https://github.com/certd/certd/commit/95122e28609333f4df55c266e5434897954c0fb3))
* 修复执行日志没有清理的bug ([22a3363](https://github.com/certd/certd/commit/22a336370a88a7df2a23c967043bae153da71ed5))
* 修复重置密码参数配置后无效的bug ([e358a88](https://github.com/certd/certd/commit/e358a8869696578687306e4cd0dcda53f898fe13))
* 修复ssh无法连接成功无法执行命令的bug ([41b9837](https://github.com/certd/certd/commit/41b9837582323fb400ef8525ce65e8b37ad4b36f))
### Features
* 支持ECC类型 ([a7424e0](https://github.com/certd/certd/commit/a7424e02f5c7e02ac1688791040785920ce67473))
* 支持google证书申请需要使用代理 ([a593056](https://github.com/certd/certd/commit/a593056e79e99dd6a74f75b5eab621af7248cfbe))
### Performance Improvements
* 更新k8s底层api库 ([746bb9d](https://github.com/certd/certd/commit/746bb9d385e2f397daef4976eca1d4782a2f5ebd))
* 优化成功后跳过的提示 ([7b451bb](https://github.com/certd/certd/commit/7b451bbf6e6337507f4627b5a845f5bd96ab4f7b))
* 优化证书申请成功率 ([968c469](https://github.com/certd/certd/commit/968c4690a07f69c08dcb3d3a494da4e319627345))
* 优化dnspod的token id 说明 ([790bf11](https://github.com/certd/certd/commit/790bf11af06d6264ef74bc1bb919661f0354239a))
* email proxy ([453f1ba](https://github.com/certd/certd/commit/453f1baa0b9eb0f648aa1b71ccf5a95b202ce13f))
## [1.23.1](https://github.com/certd/certd/compare/v1.23.0...v1.23.1) (2024-08-06)
### Bug Fixes
* 修复模糊查询无效的bug ([9355917](https://github.com/certd/certd/commit/93559174c780173f0daec7cdbd1f72f8d5c504d5))
### Performance Improvements
* 优化插件字段的default value ([24c7be2](https://github.com/certd/certd/commit/24c7be2c9cb39c14f7a97b674127c88033280b02))
* 优化默认值设置 ([1af19f0](https://github.com/certd/certd/commit/1af19f0ac053fe109782882964533636b5969d6b))
# [1.23.0](https://github.com/certd/certd/compare/v1.22.9...v1.23.0) (2024-08-05)
### Bug Fixes

30
LICENSE.md Normal file
View File

@@ -0,0 +1,30 @@
# Certd Open Source License
- This project is licensed under the **GNU Affero General Public License (AGPL)** with the following additional terms.
- 本项目遵循 GNU Affero General Public LicenseAGPL并附加以下条款。
## 1. License Terms ( 许可证条款 )
1. **Freedom to Use** (自由使用)
- You are free to use, copy, modify, and distribute the source code of this project for personal or organizational use, provided that you comply with the terms of this license.
- 您可以自由使用、复制、修改和分发本项目的源代码,前提是您遵循本许可证的条款。
2. **Modification for Personal Use** (个人使用的修改)
- Individuals and companies are allowed to modify the project according to their needs for non-commercial purposes. However, modifications to the logo, copyright information, or any code related to licensing are strictly prohibited.
- 个人和公司允许根据自身需求对本项目进行修改以供非商业用途。但任何对logo、版权信息或与许可相关代码的修改都是严格禁止的。
3. **Commercial Authorization** (商业授权)
- If you wish to make any form of monetary gain from this project, you must first obtain commercial authorization from the original author. Users should contact the author directly to negotiate the relevant licensing terms.
- 如果您希望从本项目获得任何形式的经济收益,您必须首先从原作者处获得商业授权,用户应直接与作者联系,以协商相关许可条款。
4. **Retention of Rights** (保留权利)
- All rights, title, and interest in the project remain with the original author.
- 本项目的所有权利、标题和利益仍归原作者所有。
## 2. As a contributor ( 作为贡献者 )
- you should agree that your contributed code:
- 您应同意您贡献的代码:
1. - The original author can adjust the open-source agreement to be more strict or relaxed.
- 原作者可以调整开源协议以使其更严格或更宽松。
2. - Can be used for commercial purposes.
- 可用于商业用途。

View File

@@ -1,10 +1,27 @@
# CertD
# Certd
CertD 是一个免费全自动申请和自动部署更新SSL证书的工具。
后缀D取自linux守护进程的命名风格意为证书守护进程。
Certd 是一个免费全自动申请和自动部署更新SSL证书的工具。
后缀d取自linux守护进程的命名风格意为证书守护进程。
关键字:证书自动申请、证书自动更新、证书自动续期、证书自动续签
************************
支持开源,为爱发电,我已入驻爱发电
https://afdian.com/a/greper
发电权益:
1. 可加入发电专属群,可以获得作者一对一技术支持
2. 您的需求我们将优先实现,并且将作为专业版功能提供
3. 一年期专业版激活码
4. 赠送国外免费服务器部署方案0成本使用Certd不过该服务器需要翻墙
专业版特权
1. 证书流水线条数无限制免费版限制10条
2. 免配置发邮件功能
3. FTP上传、cdnfly、宝塔等部署插件
4. 更多功能增加中...
************************
## 一、特性
本项目不仅支持证书申请过程自动化,还可以自动化部署更新证书,让你的证书永不过期。
@@ -82,8 +99,11 @@ docker compose up -d
> https://docs.docker.com/compose/install/linux/
#### 镜像说明:
* certd镜像地址:
* 国内镜像地址:
* `registry.cn-shenzhen.aliyuncs.com/handsfree/certd:latest`
* DockerHub地址
* `https://hub.docker.com/r/greper/certd`
* `docker pull greper/certd:latest`
* 镜像构建通过`Actions`自动执行,过程公开透明,请放心使用
* [点我查看镜像构建日志](https://github.com/certd/certd/actions/workflows/build-image.yml)
@@ -101,11 +121,16 @@ http://your_server_ip:7001
## 五、 升级
如果使用固定版本号
1. 修改`docker-compose.yaml`中的镜像版本号
2. 运行 `docker compose up -d` 即可
2. 运行`docker compose up -d` 即可
如果使用`latest`版本
1. 重新拉取镜像 `docker pull registry.cn-shenzhen.aliyuncs.com/handsfree/certd:latest`
2. 重新启动容器 `docker compose restart`
```shell
#重新拉取镜像
docker pull registry.cn-shenzhen.aliyuncs.com/handsfree/certd:latest
# 重新启动容器
docker compose down
docker compose up -d
```
> 数据默认存在`/data/certd`目录下,不用担心数据丢失
@@ -131,6 +156,7 @@ http://your_server_ip:7001
* [Cloudflare](./doc/cf/cf.md)
* [腾讯云](./doc/tencent/tencent.md)
* [windows主机](./doc/host/host.md)
* [google证书](./doc/google/google.md)
## 八、问题处理
@@ -169,26 +195,37 @@ docker compose up -d
</p>
## 十、捐赠
媳妇儿说:“一天到晚搞开源,也不管管老婆孩子!😡😡😡”
拜托各位捐赠支持一下,让媳妇儿开心开心,我也能有更多时间进行开源项目,感谢🙏🙏🙏
<p align="center">
<img height="380" src="./doc/images/donate.png">
</p>
支持开源,为爱发电,我已入驻爱发电
https://afdian.com/a/greper
发电权益:
1. 可加入发电专属群(先加我好友,发送发电截图,我拉你进群)
2. 你的需求优先实现
3. 可以获得作者一对一技术支持
4. 更多权益陆续增加中...
## 十一、贡献代码
[贡献插件教程](./plugin.md)
1. [贡献插件教程](./plugin.md)
2. 作为贡献者,代表您同意您贡献的代码如下许可:
1. 可以调整开源协议以使其更严格或更宽松。
2. 可以用于商业用途。
## 十二、 开源许可
* 本项目遵循 GNU Affero General Public LicenseAGPL开源协议。
* 允许个人和公司使用、复制、修改和分发本项目,禁止任何形式的商业用途
* 未获得商业授权情况下禁止任何对logo、版权信息及授权许可相关代码的修改。
* 如需商业授权,请联系作者。
## 十、我的其他项目求Star
## 十、我的其他项目求Star
* [袖手GPT](https://ai.handsfree.work/) ChatGPT国内可用无需FQ每日免费额度
* [fast-crud](https://gitee.com/fast-crud/fast-crud/) 基于vue3的crud快速开发框架
* [dev-sidecar](https://github.com/docmirror/dev-sidecar/) 直连访问github工具无需FQ解决github无法访问的问题
## 十、更新日志
## 十、更新日志
更新日志:[CHANGELOG](./CHANGELOG.md)

View File

@@ -1 +1 @@
16:10
23:58

37
doc/google/google.md Normal file
View File

@@ -0,0 +1,37 @@
# google证书申请教程
## 1、启用API
打开如下链接,启用 API
https://console.cloud.google.com/apis/library/publicca.googleapis.com
打开该链接后点击“启用”随后等待右侧出现“API已启用”则可以关闭该页。
## 2、 申请Key
随后打开“Google Cloud Shell”在右上角点击激活CloudShell图标
等待分配完成后在 Shell 窗口内输入如下命令:
```shell
gcloud beta publicca external-account-keys create
```
此时会弹出“为 Cloud Shell 提供授权”,点击授权即可。
执行完成后会返回类似如下输出;注意不要在没有收到 Google 的邮件时执行该命令,会返回命令不存在。
```shell
Created an external account key
[b64MacKey: xxxxxxxxxxxxx
keyId: xxxxxxxxx]
```
记录以上信息备用
## 3、 创建证书流水线
选择证书提供商为google 开启使用代理
## 4、 将key信息作为EAB授权信息
google证书需要EAB授权 使用第二步中的 keyId 和 b64MacKey 信息创建一条EAB授权记录
## 5、 其他就跟正常申请证书一样了

View File

@@ -1,8 +0,0 @@
# 免费服务器部署
## 1. 注册koyeb账号
https://app.koyeb.com/
## 2. 创建应用

View File

@@ -1,30 +1,38 @@
version: '3.3'
services:
certd:
# 镜像 # ↓↓↓↓↓ --- 1、 镜像版本号,建议改成固定版本号【可选】
# 镜像 # ↓↓↓↓↓ --- 镜像版本号,建议改成固定版本号【可选】
image: registry.cn-shenzhen.aliyuncs.com/handsfree/certd:latest
container_name: certd # 容器名
restart: unless-stopped # 自动重启
volumes:
# ↓↓↓↓↓ ------------------------------------------------------- 2、 数据库以及证书存储路径,默认存在宿主机的/data/certd/目录下【可选】
# ↓↓↓↓↓ -------------------------------------------------------- 数据库以及证书存储路径,默认存在宿主机的/data/certd/目录下【可选】
- /data/certd:/app/data
ports: # 端口映射
# ↓↓↓↓ ----------------------------------------------------------3、如果端口有冲突可以修改第一个7001为其他不冲突的端口号【可选】
# ↓↓↓↓ ---------------------------------------------------------- 如果端口有冲突可以修改第一个7001为其他不冲突的端口号【可选】
- "7001:7001"
dns:
# 如果出现getaddrinfo ENOTFOUND等错误可以尝试修改或注释dns配置
- 223.5.5.5
- 223.6.6.6
- 8.8.8.8
- 8.8.4.4
# ↓↓↓↓ ---------------------------------------------------------- 如果你服务器部署在国外可以用8.8.8.8替换上面的dns【可选】
# - 8.8.8.8
# - 8.8.4.4
environment: # 环境变量
- TZ=Asia/Shanghai
- certd_system_resetAdminPassword=false
# ↑↑↑↑↑---------------------------4、如果忘记管理员密码可以设置为true重启之后管理员密码将改成123456然后请及时修改回false【可选】
#- HTTPS_PROXY=http://xxxxxx:xx
#- HTTP_PROXY=http://xxxxxx:xx
# ↑↑↑↑↑ ------------------------------------- 这里可以设置http代理【可选】
- certd_system_resetAdminPasswd=false
# ↑↑↑↑↑--------------------------- 如果忘记管理员密码可以设置为true重启之后管理员密码将改成123456然后请及时修改回false【可选】
- certd_cron_immediateTriggerOnce=false
# ↑↑↑↑↑---------------------------5、如果设置为true启动后所有配置了cron的流水线任务都将被立即触发一次【可选】
# ↑↑↑↑↑--------------------------- 如果设置为true启动后所有配置了cron的流水线任务都将被立即触发一次【可选】
- VITE_APP_ICP_NO=
# ↑↑↑↑↑ -----------------------------------------6、这里可以设置备案号【可选】
# ↑↑↑↑↑ ----------------------------------------- 这里可以设置备案号【可选】
#- certd_koa_key=./data/ssl/cert.key
#- certd_koa_cert=./data/ssl/cert.crt
# ↑↑↑↑↑ ----------------------------------------- 配置证书和key则表示https方式启动访问网址要使用 https://your.domain:7001【可选】
# 设置环境变量即可自定义certd配置
# 服务端配置项见: packages/ui/certd-server/src/config/config.default.ts
# 服务端配置规则: certd_ + 配置项, 点号用_代替

20
init.sh Normal file
View File

@@ -0,0 +1,20 @@
current_pwd=$(pwd)
echo "开始设置git配置"
read -p "请输入username" username
git config user.name $username
read -p "请输入email" email
git config user.email $email
git config credential.helper "store --file=$current_pwd/.git/credential.store"
echo "已设置记住git账号密码"
git config core.autocrlf input
echo "已设置auto crlf = input"
git config core.filemode false
echo "已设置忽略文件模式变化"
echo "git配置完成"

View File

@@ -9,5 +9,5 @@
}
},
"npmClient": "pnpm",
"version": "1.23.0"
"version": "1.24.2"
}

View File

@@ -12,7 +12,7 @@
"scripts": {
"start": "lerna bootstrap --hoist",
"i-all": "lerna link && lerna exec npm install ",
"publish": "npm run prepublishOnly1 && lerna publish --conventional-commits --create-release github && npm run afterpublishOnly",
"publish": "npm run prepublishOnly2 && lerna publish --conventional-commits --create-release github && npm run afterpublishOnly",
"afterpublishOnly": "time /t >build.trigger && git add ./build.trigger && git commit -m \"build: trigger build image\" && TIMEOUT /T 10 && git push",
"prepublishOnly1": "npm run check && lerna run build ",
"prepublishOnly2": "npm run check && npm run before-build && lerna run build ",
@@ -24,9 +24,9 @@
"license": "AGPL-3.0",
"dependencies": {
"axios": "^1.7.2",
"lodash": "^4.17.21"
"lodash-es": "^4.17.21"
},
"workspaces": [
"packages/**"
]
}
}

View File

@@ -3,6 +3,39 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.24.2](https://github.com/publishlab/node-acme-client/compare/v1.24.1...v1.24.2) (2024-09-06)
### Performance Improvements
* 修复windows下无法执行第二条命令的bug ([d5bfcdb](https://github.com/publishlab/node-acme-client/commit/d5bfcdb6de1dcc1702155442e2e00237d0bbb6e5))
## [1.24.1](https://github.com/publishlab/node-acme-client/compare/v1.24.0...v1.24.1) (2024-09-02)
### Bug Fixes
* 修复在没有勾选使用代理的情况下仍然会使用代理的bug ([0f66794](https://github.com/publishlab/node-acme-client/commit/0f6679425f6a736bb0128527dd99c085fac17d84))
### Performance Improvements
* 部署插件支持宝塔、易盾云等 ([ee61709](https://github.com/publishlab/node-acme-client/commit/ee617095efa1171548cf52fd45f0f98a368555a3))
* 授权配置支持加密 ([42a56b5](https://github.com/publishlab/node-acme-client/commit/42a56b581d754c3e5f9838179d19ab0d004ef2eb))
# [1.24.0](https://github.com/publishlab/node-acme-client/compare/v1.23.1...v1.24.0) (2024-08-25)
### Bug Fixes
* 修复成功后跳过之后丢失腾讯云证书id的bug ([37eb762](https://github.com/publishlab/node-acme-client/commit/37eb762afe25c5896b75dee25f32809f8426e7b7))
* 修复创建流水线后立即运行时报no id错误的bug ([17ead54](https://github.com/publishlab/node-acme-client/commit/17ead547aab25333603980304aa3aad3db1f73d5))
* 修复使用代理的情况下申请证书失败的bug ([95122e2](https://github.com/publishlab/node-acme-client/commit/95122e28609333f4df55c266e5434897954c0fb3))
### Features
* 支持google证书申请需要使用代理 ([a593056](https://github.com/publishlab/node-acme-client/commit/a593056e79e99dd6a74f75b5eab621af7248cfbe))
### Performance Improvements
* 优化证书申请成功率 ([968c469](https://github.com/publishlab/node-acme-client/commit/968c4690a07f69c08dcb3d3a494da4e319627345))
## [1.22.6](https://github.com/publishlab/node-acme-client/compare/v1.22.5...v1.22.6) (2024-08-03)
**Note:** Version bump only for package @certd/acme-client
@@ -110,10 +143,11 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline
# Changelog
## v5.4.0
## v5.4.0 (2024-07-16)
* `added` Directory URLs for [Google](https://cloud.google.com/certificate-manager/docs/overview) ACME provider
* `fixed` Invalidate ACME directory cache after 24 hours
* `fixed` Invalidate ACME provider directory cache after 24 hours
* `fixed` Retry HTTP requests on server errors or when rate limited - [#89](https://github.com/publishlab/node-acme-client/issues/89)
## v5.3.1 (2024-05-22)
@@ -123,7 +157,7 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline
## v5.3.0 (2024-02-05)
* `added` Support and tests for satisfying `tls-alpn-01` challenges
* `changed` Replace `jsrsasign` with `@peculiar/x509` for certificate and CSR generation and parsing
* `changed` Replace `jsrsasign` with `@peculiar/x509` for certificate and CSR handling
* `changed` Method `getChallengeKeyAuthorization()` now returns `$token.$thumbprint` when called with a `tls-alpn-01` challenge
* Previously returned base64url encoded SHA256 digest of `$token.$thumbprint` erroneously
* This change is not considered breaking since the previous behavior was incorrect

View File

@@ -3,7 +3,7 @@
"description": "Simple and unopinionated ACME client",
"private": false,
"author": "nmorsman",
"version": "1.22.6",
"version": "1.24.2",
"main": "src/index.js",
"types": "types/index.d.ts",
"license": "MIT",
@@ -16,24 +16,24 @@
"types"
],
"dependencies": {
"@peculiar/x509": "^1.10.0",
"@peculiar/x509": "^1.11.0",
"asn1js": "^3.0.5",
"axios": "^1.7.2",
"debug": "^4.1.1",
"debug": "^4.3.5",
"https-proxy-agent": "^7.0.4",
"node-forge": "^1.3.1"
},
"devDependencies": {
"@types/node": "^20.12.12",
"@types/node": "^20.14.10",
"chai": "^4.4.1",
"chai-as-promised": "^7.1.2",
"eslint": "^8.57.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-plugin-import": "^2.29.1",
"jsdoc-to-markdown": "^8.0.1",
"mocha": "^10.4.0",
"mocha": "^10.6.0",
"nock": "^13.5.4",
"tsd": "^0.31.0",
"tsd": "^0.31.1",
"typescript": "^4.8.4",
"uuid": "^8.3.2"
},
@@ -59,5 +59,5 @@
"bugs": {
"url": "https://github.com/publishlab/node-acme-client/issues"
},
"gitHead": "e5da46cfc31b2e30a4903bcb2251b1851265ef41"
"gitHead": "bef6b981e26a010a797734e508de6822de8564f5"
}

View File

@@ -3,6 +3,7 @@
*/
const util = require('./util');
const { log } = require('./logger');
/**
* AcmeApi
@@ -17,6 +18,21 @@ class AcmeApi {
this.accountUrl = accountUrl;
}
getLocationFromHeader(resp) {
let locationUrl = resp.headers.location;
const mapping = this.http.urlMapping;
if (mapping.mappings) {
// eslint-disable-next-line guard-for-in,no-restricted-syntax
for (const key in mapping.mappings) {
const url = mapping.mappings[key];
if (locationUrl.indexOf(url) > -1) {
locationUrl = locationUrl.replace(url, key);
}
}
}
return locationUrl;
}
/**
* Get account URL
*
@@ -103,7 +119,7 @@ class AcmeApi {
/* Set account URL */
if (resp.headers.location) {
this.accountUrl = resp.headers.location;
this.accountUrl = this.getLocationFromHeader(resp);
}
return resp;

View File

@@ -13,8 +13,12 @@ const defaultOpts = {
termsOfServiceAgreed: false,
skipChallengeVerification: false,
challengePriority: ['http-01', 'dns-01'],
challengeCreateFn: async () => { throw new Error('Missing challengeCreateFn()'); },
challengeRemoveFn: async () => { throw new Error('Missing challengeRemoveFn()'); },
challengeCreateFn: async () => {
throw new Error('Missing challengeCreateFn()');
},
challengeRemoveFn: async () => {
throw new Error('Missing challengeRemoveFn()');
},
};
/**
@@ -137,9 +141,13 @@ module.exports = async (client, userOpts) => {
}
else {
log(`[auto] [${d}] Running challenge verification`);
await client.verifyChallenge(authz, challenge);
try {
await client.verifyChallenge(authz, challenge);
}
catch (e) {
log(`[auto] [${d}] challenge verification threw error: ${e.message}`);
}
}
/* Complete challenge and wait for valid status */
log(`[auto] [${d}] Completing challenge with ACME provider and waiting for valid status`);
await client.completeChallenge(challenge);
@@ -170,11 +178,42 @@ module.exports = async (client, userOpts) => {
throw e;
}
};
const domainSets = [];
const challengePromises = authorizations.map((authz) => async () => {
await challengeFunc(authz);
authorizations.forEach((authz) => {
const d = authz.identifier.value;
let setd = false;
// eslint-disable-next-line no-restricted-syntax
for (const group of domainSets) {
if (!group[d]) {
group[d] = authz;
setd = true;
}
}
if (!setd) {
const group = {};
group[d] = authz;
domainSets.push(group);
}
});
const allChallengePromises = [];
// eslint-disable-next-line no-restricted-syntax
for (const domainSet of domainSets) {
const challengePromises = [];
// eslint-disable-next-line guard-for-in,no-restricted-syntax
for (const domain in domainSet) {
const authz = domainSet[domain];
challengePromises.push(async () => {
log(`[auto] [${domain}] Starting challenge`);
await challengeFunc(authz);
});
}
allChallengePromises.push(challengePromises);
}
log(`[auto] challengeGroups:${allChallengePromises.length}`);
function runAllPromise(tasks) {
let promise = Promise.resolve();
tasks.forEach((task) => {
@@ -194,39 +233,48 @@ module.exports = async (client, userOpts) => {
return Promise.all(results);
}
try {
log('开始challenge');
await runPromisePa(challengePromises);
log(`开始challenge${allChallengePromises.length}`);
let i = 0;
// eslint-disable-next-line no-restricted-syntax
for (const challengePromises of allChallengePromises) {
i += 1;
log(`开始第${i}`);
if (opts.signal && opts.signal.aborted) {
throw new Error('用户取消');
}
log('challenge结束');
// log('[auto] Waiting for challenge valid status');
// await Promise.all(challengePromises);
/**
* Finalize order and download certificate
*/
log('[auto] Finalizing order and downloading certificate');
const finalized = await client.finalizeOrder(order, opts.csr);
return await client.getCertificate(finalized, opts.preferredChain);
}
catch (e) {
log('证书申请失败');
log(e);
throw new Error(`证书申请失败:${e.message}`);
}
finally {
log(`清理challenge痕迹length:${clearTasks.length}`);
try {
await runAllPromise(clearTasks);
// eslint-disable-next-line no-await-in-loop
await runPromisePa(challengePromises);
}
catch (e) {
log('清理challenge失败');
log(e);
log(`证书申请失败${e.message}`);
throw e;
}
finally {
log(`清理challenge痕迹length:${clearTasks.length}`);
try {
// eslint-disable-next-line no-await-in-loop
await runAllPromise(clearTasks);
}
catch (e) {
log('清理challenge失败');
log(e);
}
}
}
log('challenge结束');
// log('[auto] Waiting for challenge valid status');
// await Promise.all(challengePromises);
/**
* Finalize order and download certificate
*/
log('[auto] Finalizing order and downloading certificate');
const finalized = await client.finalizeOrder(order, opts.csr);
const res = await client.getCertificate(finalized, opts.preferredChain);
return res;
// try {
// await Promise.allSettled(challengePromises);
// }

View File

@@ -3,10 +3,14 @@
*/
const axios = require('axios');
const { parseRetryAfterHeader } = require('./util');
const { log } = require('./logger');
const pkg = require('./../package.json');
const { AxiosError } = axios;
/**
* Instance
* Defaults
*/
const instance = axios.create();
@@ -19,6 +23,9 @@ instance.defaults.acmeSettings = {
httpChallengePort: 80,
httpsChallengePort: 443,
tlsAlpnChallengePort: 443,
retryMaxAttempts: 5,
retryDefaultDelay: 5,
};
// instance.defaults.proxy = {
// host: '192.168.34.139',
@@ -33,6 +40,85 @@ instance.defaults.acmeSettings = {
instance.defaults.adapter = 'http';
/**
* Retry requests on server errors or when rate limited
*
* https://datatracker.ietf.org/doc/html/rfc8555#section-6.6
*/
function isRetryableError(error) {
return (error.code !== 'ECONNABORTED')
&& (error.code !== 'ERR_NOCK_NO_MATCH')
&& (!error.response
|| (error.response.status === 429)
|| ((error.response.status >= 500) && (error.response.status <= 599)));
}
/* https://github.com/axios/axios/blob/main/lib/core/settle.js */
function validateStatus(response) {
const validator = response.config.retryValidateStatus;
if (!response.status || !validator || validator(response.status)) {
return response;
}
throw new AxiosError(
`Request failed with status code ${response.status}`,
(Math.floor(response.status / 100) === 4) ? AxiosError.ERR_BAD_REQUEST : AxiosError.ERR_BAD_RESPONSE,
response.config,
response.request,
response,
);
}
/* Pass all responses through the error interceptor */
instance.interceptors.request.use((config) => {
if (!('retryValidateStatus' in config)) {
config.retryValidateStatus = config.validateStatus;
}
config.validateStatus = () => false;
return config;
});
/* Handle request retries if applicable */
instance.interceptors.response.use(null, async (error) => {
const { config, response } = error;
if (!config) {
return Promise.reject(error);
}
/* Pick up errors we want to retry */
if (isRetryableError(error)) {
const { retryMaxAttempts, retryDefaultDelay } = instance.defaults.acmeSettings;
config.retryAttempt = ('retryAttempt' in config) ? (config.retryAttempt + 1) : 1;
if (config.retryAttempt <= retryMaxAttempts) {
const code = response ? `HTTP ${response.status}` : error.code;
log(`Caught ${code}, retry attempt ${config.retryAttempt}/${retryMaxAttempts} to URL ${config.url}`);
/* Attempt to parse Retry-After header, fallback to default delay */
let retryAfter = response ? parseRetryAfterHeader(response.headers['retry-after']) : 0;
if (retryAfter > 0) {
log(`Found retry-after response header with value: ${response.headers['retry-after']}, waiting ${retryAfter} seconds`);
}
else {
retryAfter = (retryDefaultDelay * config.retryAttempt);
log(`Unable to locate or parse retry-after response header, waiting ${retryAfter} seconds`);
}
/* Wait and retry the request */
await new Promise((resolve) => { setTimeout(resolve, (retryAfter * 1000)); });
return instance(config);
}
}
/* Validate and return response */
return validateStatus(response);
});
/**
* Export instance
*/

View File

@@ -300,7 +300,8 @@ class AcmeClient {
}
/* Add URL to response */
resp.data.url = resp.headers.location;
resp.data.url = this.api.getLocationFromHeader(resp);
return resp.data;
}
@@ -490,6 +491,9 @@ class AcmeClient {
const keyAuthorization = await this.getChallengeKeyAuthorization(challenge);
const verifyFn = async () => {
if (this.opts.signal && this.opts.signal.aborted) {
throw new Error('用户取消');
}
await verify[challenge.type](authz, challenge, keyAuthorization);
};
@@ -513,6 +517,9 @@ class AcmeClient {
*/
async completeChallenge(challenge) {
if (this.opts.signal && this.opts.signal.aborted) {
throw new Error('用户取消');
}
const resp = await this.api.completeChallenge(challenge.url, {});
return resp.data;
}
@@ -550,6 +557,10 @@ class AcmeClient {
}
const verifyFn = async (abort) => {
if (this.opts.signal && this.opts.signal.aborted) {
throw new Error('用户取消');
}
const resp = await this.api.apiRequest(item.url, null, [200]);
/* Verify status */

View File

@@ -10,6 +10,7 @@
const net = require('net');
const { promisify } = require('util');
const forge = require('node-forge');
const { createPrivateEcdsaKey, getPublicKey } = require('./index');
const generateKeyPair = promisify(forge.pki.rsa.generateKeyPair);
@@ -378,13 +379,17 @@ function formatCsrAltNames(altNames) {
* }, certificateKey);
*/
exports.createCsr = async (data, key = null) => {
if (!key) {
exports.createCsr = async (data, keyType = null) => {
let key = null;
if (keyType === 'ec') {
key = await createPrivateEcdsaKey();
}
else {
key = await createPrivateKey(data.keySize);
}
else if (!Buffer.isBuffer(key)) {
key = Buffer.from(key);
}
// else if (!Buffer.isBuffer(key)) {
// key = Buffer.from(key);
// }
if (typeof data.altNames === 'undefined') {
data.altNames = [];
@@ -396,6 +401,8 @@ exports.createCsr = async (data, key = null) => {
const privateKey = forge.pki.privateKeyFromPem(key);
const publicKey = forge.pki.rsa.setPublicKey(privateKey.n, privateKey.e);
csr.publicKey = publicKey;
// const privateKey = key;
// csr.publicKey = getPublicKey(key);
/* Ensure subject common name is present in SAN - https://cabforum.org/wp-content/uploads/BRv1.2.3.pdf */
if (data.commonName && !data.altNames.includes(data.commonName)) {

View File

@@ -290,7 +290,6 @@ exports.readCsrDomains = (csrPem) => {
if (Buffer.isBuffer(csrPem)) {
csrPem = csrPem.toString();
}
const dec = x509.PemConverter.decodeFirst(csrPem);
const csr = new x509.Pkcs10CertificateRequest(dec);
return parseDomains(csr);

View File

@@ -55,7 +55,7 @@ class HttpClient {
*/
async request(url, method, opts = {}) {
if (this.urlMapping && this.urlMapping.enabled === true && this.urlMapping.mappings) {
if (this.urlMapping && this.urlMapping.enabled && this.urlMapping.mappings) {
// eslint-disable-next-line no-restricted-syntax
for (const key in this.urlMapping.mappings) {
if (url.includes(key)) {
@@ -93,9 +93,11 @@ class HttpClient {
*/
async getDirectory() {
const age = (Math.floor(Date.now() / 1000) - this.directoryTimestamp);
const now = Math.floor(Date.now() / 1000);
const age = (now - this.directoryTimestamp);
if (!this.directoryCache || (age > this.directoryMaxAge)) {
log(`Refreshing ACME directory, age: ${age}`);
const resp = await this.request(this.directoryUrl, 'get');
if (resp.status >= 400) {
@@ -107,6 +109,7 @@ class HttpClient {
}
this.directoryCache = resp.data;
this.directoryTimestamp = now;
}
return this.directoryCache;
@@ -131,7 +134,7 @@ class HttpClient {
*
* https://datatracker.ietf.org/doc/html/rfc8555#section-7.2
*
* @returns {Promise<string>} nonce
* @returns {Promise<string>} Nonce
*/
async getNonce() {

View File

@@ -32,7 +32,7 @@ exports.directory = {
*/
exports.crypto = require('./crypto');
exports.forge = require('./crypto/forge');
// exports.forge = require('./crypto/forge');
/**
* Axios

View File

@@ -84,9 +84,12 @@ function retry(fn, { attempts = 5, min = 5000, max = 30000 } = {}) {
}
/**
* Parse URLs from link header
* Parse URLs from Link header
*
* @param {string} header Link header contents
* https://datatracker.ietf.org/doc/html/rfc8555#section-7.4.2
* https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Link
*
* @param {string} header Header contents
* @param {string} rel Link relation, default: `alternate`
* @returns {string[]} Array of URLs
*/
@@ -102,6 +105,37 @@ function parseLinkHeader(header, rel = 'alternate') {
return results.filter((r) => r);
}
/**
* Parse date or duration from Retry-After header
*
* https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After
*
* @param {string} header Header contents
* @returns {number} Retry duration in seconds
*/
function parseRetryAfterHeader(header) {
const sec = parseInt(header, 10);
const date = new Date(header);
/* Seconds into the future */
if (Number.isSafeInteger(sec) && (sec > 0)) {
return sec;
}
/* Future date string */
if (date instanceof Date && !Number.isNaN(date)) {
const now = new Date();
const diff = Math.ceil((date.getTime() - now.getTime()) / 1000);
if (diff > 0) {
return diff;
}
}
return 0;
}
/**
* Find certificate chain with preferred issuer common name
* - If issuer is found in multiple chains, the closest to root wins
@@ -161,14 +195,16 @@ function findCertificateChainForIssuer(chains, issuer) {
function formatResponseError(resp) {
let result;
if (resp.data.error) {
result = resp.data.error.detail || resp.data.error;
}
else {
result = resp.data.detail || JSON.stringify(resp.data);
if (resp.data) {
if (resp.data.error) {
result = resp.data.error.detail || resp.data.error;
}
else {
result = resp.data.detail || JSON.stringify(resp.data);
}
}
return result.replace(/\n/g, '');
return (result || '').replace(/\n/g, '');
}
/**
@@ -296,6 +332,7 @@ async function retrieveTlsAlpnCertificate(host, port, timeout = 30000) {
module.exports = {
retry,
parseLinkHeader,
parseRetryAfterHeader,
findCertificateChainForIssuer,
formatResponseError,
getAuthoritativeDnsResolver,

View File

@@ -111,7 +111,7 @@ async function verifyDnsChallenge(authz, challenge, keyAuthorization, prefix = '
log(`DNS query finished successfully, found ${recordValues.length} TXT records`);
if (!recordValues.length || !recordValues.includes(keyAuthorization)) {
throw new Error(`Authorization not found in DNS TXT record: ${recordName}`);
throw new Error(`Authorization not found in DNS TXT record: ${recordName}need:${keyAuthorization},found:${recordValues}`);
}
log(`Key authorization match for ${challenge.type}/${recordName}, ACME challenge verified`);

View File

@@ -12,33 +12,12 @@ const pkg = require('./../package.json');
describe('http', () => {
let testClient;
const endpoint = `http://${uuid()}.example.com`;
const defaultUserAgent = `node-${pkg.name}/${pkg.version}`;
const customUserAgent = 'custom-ua-123';
const primaryEndpoint = `http://${uuid()}.example.com`;
const defaultUaEndpoint = `http://${uuid()}.example.com`;
const customUaEndpoint = `http://${uuid()}.example.com`;
/**
* HTTP mocking
*/
before(() => {
const defaultUaOpts = { reqheaders: { 'User-Agent': defaultUserAgent } };
const customUaOpts = { reqheaders: { 'User-Agent': customUserAgent } };
nock(primaryEndpoint)
.persist().get('/').reply(200, 'ok');
nock(defaultUaEndpoint, defaultUaOpts)
.persist().get('/').reply(200, 'ok');
nock(customUaEndpoint, customUaOpts)
.persist().get('/').reply(200, 'ok');
});
after(() => {
axios.defaults.headers.common['User-Agent'] = defaultUserAgent;
afterEach(() => {
nock.cleanAll();
});
/**
@@ -54,7 +33,8 @@ describe('http', () => {
*/
it('should http get', async () => {
const resp = await testClient.request(primaryEndpoint, 'get');
nock(endpoint).get('/').reply(200, 'ok');
const resp = await testClient.request(endpoint, 'get');
assert.isObject(resp);
assert.strictEqual(resp.status, 200);
@@ -66,28 +46,76 @@ describe('http', () => {
*/
it('should request using default user-agent', async () => {
const resp = await testClient.request(defaultUaEndpoint, 'get');
nock(endpoint).matchHeader('user-agent', defaultUserAgent).get('/').reply(200, 'ok');
axios.defaults.headers.common['User-Agent'] = defaultUserAgent;
const resp = await testClient.request(endpoint, 'get');
assert.isObject(resp);
assert.strictEqual(resp.status, 200);
assert.strictEqual(resp.data, 'ok');
});
it('should not request using custom user-agent', async () => {
await assert.isRejected(testClient.request(customUaEndpoint, 'get'));
it('should reject using custom user-agent', async () => {
nock(endpoint).matchHeader('user-agent', defaultUserAgent).get('/').reply(200, 'ok');
axios.defaults.headers.common['User-Agent'] = customUserAgent;
await assert.isRejected(testClient.request(endpoint, 'get'));
});
it('should request using custom user-agent', async () => {
nock(endpoint).matchHeader('user-agent', customUserAgent).get('/').reply(200, 'ok');
axios.defaults.headers.common['User-Agent'] = customUserAgent;
const resp = await testClient.request(customUaEndpoint, 'get');
const resp = await testClient.request(endpoint, 'get');
assert.isObject(resp);
assert.strictEqual(resp.status, 200);
assert.strictEqual(resp.data, 'ok');
});
it('should not request using default user-agent', async () => {
axios.defaults.headers.common['User-Agent'] = customUserAgent;
await assert.isRejected(testClient.request(defaultUaEndpoint, 'get'));
it('should reject using default user-agent', async () => {
nock(endpoint).matchHeader('user-agent', customUserAgent).get('/').reply(200, 'ok');
axios.defaults.headers.common['User-Agent'] = defaultUserAgent;
await assert.isRejected(testClient.request(endpoint, 'get'));
});
/**
* Retry on HTTP errors
*/
it('should retry on 429 rate limit', async () => {
let rateLimitCount = 0;
nock(endpoint).persist().get('/').reply(() => {
rateLimitCount += 1;
if (rateLimitCount < 3) {
return [429, 'Rate Limit Exceeded', { 'Retry-After': 1 }];
}
return [200, 'ok'];
});
assert.strictEqual(rateLimitCount, 0);
const resp = await testClient.request(endpoint, 'get');
assert.isObject(resp);
assert.strictEqual(resp.status, 200);
assert.strictEqual(resp.data, 'ok');
assert.strictEqual(rateLimitCount, 3);
});
it('should retry on 5xx server error', async () => {
let serverErrorCount = 0;
nock(endpoint).persist().get('/').reply(() => {
serverErrorCount += 1;
return [500, 'Internal Server Error', { 'Retry-After': 1 }];
});
assert.strictEqual(serverErrorCount, 0);
const resp = await testClient.request(endpoint, 'get');
assert.isObject(resp);
assert.strictEqual(resp.status, 500);
assert.strictEqual(serverErrorCount, 4);
});
});

View File

@@ -0,0 +1,145 @@
/**
* Utility method tests
*/
const dns = require('dns').promises;
const fs = require('fs').promises;
const path = require('path');
const { assert } = require('chai');
const util = require('./../src/util');
const { readCertificateInfo } = require('./../src/crypto');
describe('util', () => {
const testCertPath1 = path.join(__dirname, 'fixtures', 'certificate.crt');
const testCertPath2 = path.join(__dirname, 'fixtures', 'letsencrypt.crt');
it('retry()', async () => {
let attempts = 0;
const backoffOpts = {
min: 100,
max: 500,
};
await assert.isRejected(util.retry(() => {
throw new Error('oops');
}, backoffOpts));
const r = await util.retry(() => {
attempts += 1;
if (attempts < 3) {
throw new Error('oops');
}
return 'abc';
}, backoffOpts);
assert.strictEqual(r, 'abc');
assert.strictEqual(attempts, 3);
});
it('parseLinkHeader()', () => {
const r1 = util.parseLinkHeader('<https://example.com/a>;rel="alternate"');
assert.isArray(r1);
assert.strictEqual(r1.length, 1);
assert.strictEqual(r1[0], 'https://example.com/a');
const r2 = util.parseLinkHeader('<https://example.com/b>;rel="test"');
assert.isArray(r2);
assert.strictEqual(r2.length, 0);
const r3 = util.parseLinkHeader('<http://example.com/c>; rel="test"', 'test');
assert.isArray(r3);
assert.strictEqual(r3.length, 1);
assert.strictEqual(r3[0], 'http://example.com/c');
const r4 = util.parseLinkHeader(`<https://example.com/a>; rel="alternate",
<https://example.com/x>; rel="nope",
<https://example.com/b>;rel="alternate",
<https://example.com/c>; rel="alternate"`);
assert.isArray(r4);
assert.strictEqual(r4.length, 3);
assert.strictEqual(r4[0], 'https://example.com/a');
assert.strictEqual(r4[1], 'https://example.com/b');
assert.strictEqual(r4[2], 'https://example.com/c');
});
it('parseRetryAfterHeader()', () => {
const r1 = util.parseRetryAfterHeader('');
assert.strictEqual(r1, 0);
const r2 = util.parseRetryAfterHeader('abcdef');
assert.strictEqual(r2, 0);
const r3 = util.parseRetryAfterHeader('123');
assert.strictEqual(r3, 123);
const r4 = util.parseRetryAfterHeader('123.456');
assert.strictEqual(r4, 123);
const r5 = util.parseRetryAfterHeader('-555');
assert.strictEqual(r5, 0);
const r6 = util.parseRetryAfterHeader('Wed, 21 Oct 2015 07:28:00 GMT');
assert.strictEqual(r6, 0);
const now = new Date();
const future = new Date(now.getTime() + 123000);
const r7 = util.parseRetryAfterHeader(future.toUTCString());
assert.isTrue(r7 > 100);
});
it('findCertificateChainForIssuer()', async () => {
const certs = [
(await fs.readFile(testCertPath1)).toString(),
(await fs.readFile(testCertPath2)).toString(),
];
const r1 = util.findCertificateChainForIssuer(certs, 'abc123');
const r2 = util.findCertificateChainForIssuer(certs, 'example.com');
const r3 = util.findCertificateChainForIssuer(certs, 'E6');
[r1, r2, r3].forEach((r) => {
assert.isString(r);
assert.isNotEmpty(r);
});
assert.strictEqual(readCertificateInfo(r1).issuer.commonName, 'example.com');
assert.strictEqual(readCertificateInfo(r2).issuer.commonName, 'example.com');
assert.strictEqual(readCertificateInfo(r3).issuer.commonName, 'E6');
});
it('formatResponseError()', () => {
const e1 = util.formatResponseError({ data: { error: 'aaa' } });
assert.strictEqual(e1, 'aaa');
const e2 = util.formatResponseError({ data: { error: { detail: 'bbb' } } });
assert.strictEqual(e2, 'bbb');
const e3 = util.formatResponseError({ data: { detail: 'ccc' } });
assert.strictEqual(e3, 'ccc');
const e4 = util.formatResponseError({ data: { a: 123 } });
assert.strictEqual(e4, '{"a":123}');
const e5 = util.formatResponseError({});
assert.isString(e5);
assert.isEmpty(e5);
});
it('getAuthoritativeDnsResolver()', async () => {
/* valid domain - should not use global default */
const r1 = await util.getAuthoritativeDnsResolver('example.com');
assert.instanceOf(r1, dns.Resolver);
assert.isNotEmpty(r1.getServers());
assert.notDeepEqual(r1.getServers(), dns.getServers());
/* invalid domain - fallback to global default */
const r2 = await util.getAuthoritativeDnsResolver('invalid.xtldx');
assert.instanceOf(r2, dns.Resolver);
assert.deepStrictEqual(r2.getServers(), dns.getServers());
});
/* TODO: Figure out how to test this */
it('retrieveTlsAlpnCertificate()');
});

View File

@@ -0,0 +1,23 @@
-----BEGIN CERTIFICATE-----
MIIDzzCCA1WgAwIBAgISA0ghDoSv5DpT3Pd3lqwjbVDDMAoGCCqGSM49BAMDMDIx
CzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQDEwJF
NjAeFw0yNDA2MTAxNzEyMjZaFw0yNDA5MDgxNzEyMjVaMBQxEjAQBgNVBAMTCWxl
bmNyLm9yZzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABEHJ3DjN7pYV3mftHzaP
V/WI0RhOJnSI5AIFEPFHDi8UowOINRGIfm9FHGIDqrb4Rmyvr9JrrqBdFGDen8BW
6OGjggJnMIICYzAOBgNVHQ8BAf8EBAMCB4AwHQYDVR0lBBYwFAYIKwYBBQUHAwEG
CCsGAQUFBwMCMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYEFIdCTnxqmpOELDyzPaEM
seB36lUOMB8GA1UdIwQYMBaAFJMnRpgDqVFojpjWxEJI2yO/WJTSMFUGCCsGAQUF
BwEBBEkwRzAhBggrBgEFBQcwAYYVaHR0cDovL2U2Lm8ubGVuY3Iub3JnMCIGCCsG
AQUFBzAChhZodHRwOi8vZTYuaS5sZW5jci5vcmcvMG8GA1UdEQRoMGaCCWxlbmNy
Lm9yZ4IPbGV0c2VuY3J5cHQuY29tgg9sZXRzZW5jcnlwdC5vcmeCDXd3dy5sZW5j
ci5vcmeCE3d3dy5sZXRzZW5jcnlwdC5jb22CE3d3dy5sZXRzZW5jcnlwdC5vcmcw
EwYDVR0gBAwwCjAIBgZngQwBAgEwggEFBgorBgEEAdZ5AgQCBIH2BIHzAPEAdgA/
F0tP1yJHWJQdZRyEvg0S7ZA3fx+FauvBvyiF7PhkbgAAAZADWfneAAAEAwBHMEUC
IGlp+dPU2hLT2suTMYkYMlt/xbzSnKLZDA/wYSsPACP7AiEAxbAzx6mkzn0cs0hh
ti6sLf0pcbmDhxHdlJRjuo6SQZEAdwDf4VbrqgWvtZwPhnGNqMAyTq5W2W6n9aVq
AdHBO75SXAAAAZADWfqrAAAEAwBIMEYCIQCrAmDUrlX3oGhri1qCIb65Cuf8h2GR
LC1VfXBenX7dCAIhALXwbhCQ1vO1WLv4CqyihMHOwFaICYqN/N6ylaBlVAM4MAoG
CCqGSM49BAMDA2gAMGUCMFdgjOXGl+hE2ABDsAeuNq8wi34yTMUHk0KMTOjRAfy9
rOCGQqvP0myoYlyzXOH9uQIxAMdkG1ZWBZS1dHavbPf1I/MjYpzX6gy0jVHIXXu5
aYWylBi/Uf2RPj0LWFZh8tNa1Q==
-----END CERTIFICATE-----

View File

@@ -29,6 +29,13 @@ if (process.env.ACME_TLSALPN_PORT) {
axios.defaults.acmeSettings.tlsAlpnChallengePort = process.env.ACME_TLSALPN_PORT;
}
/**
* Greatly reduce retry duration while testing
*/
axios.defaults.acmeSettings.retryMaxAttempts = 3;
axios.defaults.acmeSettings.retryDefaultDelay = 1;
/**
* External account binding
*/

View File

@@ -45,6 +45,7 @@ export interface ClientOptions {
backoffMin?: number;
backoffMax?: number;
urlMapping?: UrlMapping;
signal?: AbortSignal;
}
export interface ClientExternalAccountBindingOptions {
@@ -61,6 +62,7 @@ export interface ClientAutoOptions {
skipChallengeVerification?: boolean;
challengePriority?: string[];
preferredChain?: string;
signal?: AbortSignal;
}
export class Client {

View File

@@ -3,6 +3,43 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.24.2](https://github.com/certd/certd/compare/v1.24.1...v1.24.2) (2024-09-06)
### Performance Improvements
* 优化跳过处理逻辑 ([b80210f](https://github.com/certd/certd/commit/b80210f24bf5db1c958d06ab27c9e5d3db452eda))
* 支持pfx、der ([fbeaed2](https://github.com/certd/certd/commit/fbeaed203519f59b6d9396c4e8953353ccb5e723))
## [1.24.1](https://github.com/certd/certd/compare/v1.24.0...v1.24.1) (2024-09-02)
### Performance Improvements
* 部署插件支持宝塔、易盾云等 ([ee61709](https://github.com/certd/certd/commit/ee617095efa1171548cf52fd45f0f98a368555a3))
* 授权配置支持加密 ([42a56b5](https://github.com/certd/certd/commit/42a56b581d754c3e5f9838179d19ab0d004ef2eb))
* 支持阿里云 DCDN ([98b77f8](https://github.com/certd/certd/commit/98b77f80843834616fb26f83b4c42245326abd06))
* 支持已跳过的步骤重新运行 ([ea775ad](https://github.com/certd/certd/commit/ea775adae18d57a04470cfba6b9460d761d74035))
# [1.24.0](https://github.com/certd/certd/compare/v1.23.1...v1.24.0) (2024-08-25)
### Bug Fixes
* 修复成功后跳过之后丢失腾讯云证书id的bug ([37eb762](https://github.com/certd/certd/commit/37eb762afe25c5896b75dee25f32809f8426e7b7))
* 修复执行日志没有清理的bug ([22a3363](https://github.com/certd/certd/commit/22a336370a88a7df2a23c967043bae153da71ed5))
### Features
* 支持google证书申请需要使用代理 ([a593056](https://github.com/certd/certd/commit/a593056e79e99dd6a74f75b5eab621af7248cfbe))
### Performance Improvements
* 优化成功后跳过的提示 ([7b451bb](https://github.com/certd/certd/commit/7b451bbf6e6337507f4627b5a845f5bd96ab4f7b))
* 优化证书申请成功率 ([968c469](https://github.com/certd/certd/commit/968c4690a07f69c08dcb3d3a494da4e319627345))
* email proxy ([453f1ba](https://github.com/certd/certd/commit/453f1baa0b9eb0f648aa1b71ccf5a95b202ce13f))
## [1.23.1](https://github.com/certd/certd/compare/v1.23.0...v1.23.1) (2024-08-06)
**Note:** Version bump only for package @certd/pipeline
## [1.22.8](https://github.com/certd/certd/compare/v1.22.7...v1.22.8) (2024-08-05)
### Performance Improvements

View File

@@ -1 +1 @@
22:30
22:33

View File

@@ -1,7 +1,7 @@
{
"name": "@certd/pipeline",
"private": false,
"version": "1.22.8",
"version": "1.24.2",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -14,6 +14,7 @@
"test": "mocha --loader=ts-node/esm"
},
"dependencies": {
"@certd/plus": "1.22.1",
"axios": "^1.7.2",
"fix-path": "^4.0.0",
"lodash-es": "^4.17.21",
@@ -56,5 +57,5 @@
"vite": "^4.3.8",
"vue-tsc": "^1.6.5"
},
"gitHead": "e5da46cfc31b2e30a4903bcb2251b1851265ef41"
"gitHead": "bef6b981e26a010a797734e508de6822de8564f5"
}

View File

@@ -4,6 +4,7 @@ import { FormItemProps } from "../dt/index.js";
export type AccessInputDefine = FormItemProps & {
title: string;
required?: boolean;
encrypt?: boolean;
};
export type AccessDefine = Registrable & {
input?: {

View File

@@ -12,6 +12,7 @@ import { RegistryItem } from "../registry/index.js";
import { Decorator } from "../decorator/index.js";
import { IEmailService } from "../service/index.js";
import { FileStore } from "./file-store.js";
import { hashUtils } from "../utils/index.js";
// import { TimeoutPromise } from "../utils/util.promise.js";
export type ExecutorOptions = {
@@ -23,16 +24,21 @@ export type ExecutorOptions = {
emailService: IEmailService;
fileRootDir?: string;
};
export class Executor {
pipeline: Pipeline;
runtime!: RunHistory;
contextFactory: ContextFactory;
logger: Logger;
pipelineContext!: IContext;
currentStatusMap!: RunnableCollection;
lastStatusMap!: RunnableCollection;
lastRuntime!: RunHistory;
options: ExecutorOptions;
canceled = false;
abort: AbortController = new AbortController();
_inited = false;
onChanged: (history: RunHistory) => Promise<void>;
constructor(options: ExecutorOptions) {
this.options = options;
@@ -47,18 +53,24 @@ export class Executor {
}
async init() {
if (this._inited) {
return;
}
this._inited = true;
const lastRuntime = await this.pipelineContext.getObj(`lastRuntime`);
this.lastRuntime = lastRuntime;
this.lastStatusMap = new RunnableCollection(lastRuntime?.pipeline);
this.currentStatusMap = new RunnableCollection(this.pipeline);
}
async cancel() {
this.canceled = true;
this.abort.abort();
this.runtime?.cancel(this.pipeline);
await this.onChanged(this.runtime);
}
async run(runtimeId: any = 0, triggerType: string) {
let intervalFlushLogId: any = undefined;
try {
await this.init();
const trigger = { type: triggerType };
@@ -66,57 +78,50 @@ export class Executor {
this.runtime = new RunHistory(runtimeId, trigger, this.pipeline);
this.logger.info(`pipeline.${this.pipeline.id} start`);
await this.notification("start");
this.runtime.start(this.pipeline);
intervalFlushLogId = setInterval(async () => {
await this.onChanged(this.runtime);
}, 5000);
await this.runWithHistory(this.pipeline, "pipeline", async () => {
await this.runStages(this.pipeline);
return await this.runStages(this.pipeline);
});
if (this.lastRuntime && this.lastRuntime.pipeline.status?.status === ResultType.error) {
await this.notification("turnToSuccess");
}
await this.notification("success");
} catch (e) {
} catch (e: any) {
await this.notification("error", e);
this.logger.error("pipeline 执行失败", e);
this.logger.error("pipeline 执行失败", e.stack);
} finally {
clearInterval(intervalFlushLogId);
await this.onChanged(this.runtime);
await this.pipelineContext.setObj("lastRuntime", this.runtime);
this.logger.info(`pipeline.${this.pipeline.id} end`);
}
}
async runWithHistory(runnable: Runnable, runnableType: string, run: () => Promise<void>) {
async runWithHistory(runnable: Runnable, runnableType: string, run: () => Promise<ResultType | void>) {
runnable.runnableType = runnableType;
this.runtime.start(runnable);
// const timeout = runnable.timeout ?? 20 * 60 * 1000;
await this.onChanged(this.runtime);
if (runnable.strategy?.runStrategy === RunStrategy.SkipWhenSucceed) {
//如果是成功后跳过策略
const lastNode = this.lastStatusMap.get(runnable.id);
const lastResult = lastNode?.status?.status;
const lastInput = JSON.stringify(lastNode?.status?.input);
let inputChanged = false;
if (runnableType === "step") {
const step = runnable as Step;
const input = JSON.stringify(step.input);
if (input != null && lastInput !== input) {
//参数有变化
inputChanged = true;
}
}
if (lastResult != null && lastResult === ResultType.success && !inputChanged) {
this.runtime.skip(runnable);
await this.onChanged(this.runtime);
return ResultType.skip;
}
}
const intervalFlushLogId = setInterval(async () => {
await this.onChanged(this.runtime);
}, 5000);
// const timeout = runnable.timeout ?? 20 * 60 * 1000;
try {
if (this.canceled) {
if (this.abort.signal.aborted) {
this.runtime.cancel(runnable);
return ResultType.canceled;
}
await run();
const resultType = await run();
if (this.abort.signal.aborted) {
this.runtime.cancel(runnable);
return ResultType.canceled;
}
if (resultType == ResultType.skip) {
this.runtime.skip(runnable);
return resultType;
}
this.runtime.success(runnable);
return ResultType.success;
} catch (e: any) {
@@ -124,7 +129,6 @@ export class Executor {
throw e;
} finally {
this.runtime.finally(runnable);
clearInterval(intervalFlushLogId);
await this.onChanged(this.runtime);
}
}
@@ -133,7 +137,7 @@ export class Executor {
const resList: ResultType[] = [];
for (const stage of pipeline.stages) {
const res: ResultType = await this.runWithHistory(stage, "stage", async () => {
await this.runStage(stage);
return await this.runStage(stage);
});
resList.push(res);
}
@@ -146,6 +150,7 @@ export class Executor {
const runner = async () => {
return this.runWithHistory(task, "task", async () => {
await this.runTask(task);
return ResultType.success;
});
};
runnerList.push(runner);
@@ -188,7 +193,7 @@ export class Executor {
for (const step of task.steps) {
step.runnableType = "step";
const res: ResultType = await this.runWithHistory(step, "step", async () => {
await this.runStep(step);
return await this.runStep(step);
});
resList.push(res);
}
@@ -197,7 +202,7 @@ export class Executor {
private async runStep(step: Step) {
const currentLogger = this.runtime._loggers[step.id];
this.currentStatusMap.add(step);
const lastStatus = this.lastStatusMap.get(step.id);
//执行任务
const plugin: RegistryItem<AbstractTaskPlugin> = pluginRegistry.get(step.type);
@@ -207,24 +212,40 @@ export class Executor {
// @ts-ignore
const define: PluginDefine = plugin.define;
//从outputContext读取输入参数
Decorator.inject(define.input, instance, step.input, (item, key) => {
const input = _.cloneDeep(step.input);
Decorator.inject(define.input, instance, input, (item, key) => {
if (item.component?.name === "pi-output-selector") {
const contextKey = step.input[key];
const contextKey = input[key];
if (contextKey != null) {
const value = this.runtime.context[contextKey];
if (value == null) {
currentLogger.warn(`[step init] input ${define.title} is null前置任务步骤输出值为空请按如下步骤排查`);
currentLogger.log(`1、检查前置任务证书申请任务是否是配置了成功后跳过如果是请改为正常执行`);
currentLogger.log(
`2、是否曾经删除过前置任务证书申请任务然后又重新添加了如果是请重新编辑当前任务重新选择一下前置任务输出的参数域名证书那一栏`
);
currentLogger.log(`3、以上都不是联系作者吧`);
}
step.input[key] = value;
// "cert": "step.-BNFVPMKPu2O-i9NiOQxP.cert",
const arr = contextKey.split(".");
const id = arr[1];
const outputKey = arr[2];
input[key] = this.currentStatusMap.get(id)?.status?.output[outputKey] ?? this.lastStatusMap.get(id)?.status?.output[outputKey];
}
}
});
const newInputHash = hashUtils.md5(JSON.stringify(input));
step.status!.inputHash = newInputHash;
//判断是否需要跳过
const lastNode = this.lastStatusMap.get(step.id);
const lastResult = lastNode?.status?.status;
if (step.strategy?.runStrategy === RunStrategy.SkipWhenSucceed) {
//如果是成功后跳过策略
let inputChanged = true;
const lastInputHash = lastNode?.status?.inputHash;
if (lastInputHash && newInputHash && lastInputHash === newInputHash) {
//参数有变化
inputChanged = false;
}
if (lastResult != null && lastResult === ResultType.success && !inputChanged) {
step.status!.output = lastNode?.status?.output;
step.status!.files = lastNode?.status?.files;
return ResultType.skip;
}
}
const http = createAxiosService({ logger: currentLogger });
const taskCtx: TaskInstanceContext = {
pipeline: this.pipeline,
@@ -241,6 +262,7 @@ export class Executor {
parent: this.runtime.id,
rootDir: this.options.fileRootDir,
}),
signal: this.abort.signal,
};
instance.setCtx(taskCtx);
@@ -254,11 +276,10 @@ export class Executor {
//输出上下文变量到output context
_.forEach(define.output, (item: any, key: any) => {
step.status!.output[key] = instance[key];
const stepOutputKey = `step.${step.id}.${key}`;
this.runtime.context[stepOutputKey] = instance[key];
// const stepOutputKey = `step.${step.id}.${key}`;
// this.runtime.context[stepOutputKey] = instance[key];
});
step.status!.files = instance.getFiles();
//更新pipeline vars
if (Object.keys(instance._result.pipelineVars).length > 0) {
// 判断 pipelineVars 有值时更新
@@ -275,17 +296,17 @@ export class Executor {
let subject = "";
let content = "";
if (when === "start") {
subject = `【CertD】开始执行${this.pipeline.title}, buildId:${this.runtime.id}`;
content = subject;
subject = `【CertD】开始执行${this.pipeline.id}${this.pipeline.title}`;
content = `buildId:${this.runtime.id}`;
} else if (when === "success") {
subject = `【CertD】执行成功${this.pipeline.title}, buildId:${this.runtime.id}`;
content = subject;
subject = `【CertD】执行成功${this.pipeline.id}${this.pipeline.title}`;
content = `buildId:${this.runtime.id}`;
} else if (when === "turnToSuccess") {
subject = `【CertD】执行成功错误转成功${this.pipeline.title}, buildId:${this.runtime.id}`;
content = subject;
subject = `【CertD】执行成功错误转成功${this.pipeline.id}${this.pipeline.title}`;
content = `buildId:${this.runtime.id}`;
} else if (when === "error") {
subject = `【CertD】执行失败${this.pipeline.title}, buildId:${this.runtime.id}`;
content = `<pre>${error.message}</pre>`;
subject = `【CertD】执行失败${this.pipeline.id}${this.pipeline.title}`;
content = `buildId:${this.runtime.id}\nerror:${error.message}`;
} else {
return;
}
@@ -308,4 +329,8 @@ export class Executor {
}
}
}
clearLastStatus(stepId: string) {
this.lastStatusMap.clearById(stepId);
}
}

View File

@@ -54,7 +54,10 @@ export class FileStore {
deleteByParent(scope: string, parent: string) {
const dir = path.join(this.rootDir, scope, parent);
if (fs.existsSync(dir)) {
fs.unlinkSync(dir);
fs.rmSync(dir, {
recursive: true,
force: true,
});
}
}
}

View File

@@ -1,15 +0,0 @@
import { isPlus, verify } from "./license.js";
import { equal } from "assert";
describe("license", function () {
it("#license", async function () {
const req = {
appKey: "z4nXOeTeSnnpUpnmsV",
subjectId: "999",
license: "",
};
const plus = isPlus();
equal(plus, false);
const res = await verify(req);
equal(res, true);
});
});

View File

@@ -1,85 +1,10 @@
import { createVerify } from "node:crypto";
import { logger } from "../utils/index.js";
import { setLogger, isPlus } from "@certd/plus";
setLogger(logger);
export * from "@certd/plus";
const SecreteKey =
"LS0tLS1CRUdJTiBSU0EgUFVCTElDIEtFWS0tLS0tCk1JSUJDZ0tDQVFFQXY3TGtMaUp1dGM0NzhTU3RaTExjajVGZXh1YjJwR2NLMGxwa0hwVnlZWjhMY29rRFhuUlAKUGQ5UlJSTVRTaGJsbFl2Mzd4QUhOV1ZIQ0ZsWHkrQklVU001bUlBU1NDQTV0azlJNmpZZ2F4bEFDQm1BY0lGMwozKzBjeGZIYVkrVW9YdVluMkZ6YUt2Ym5GdFZIZ0lkMDg4a3d4clZTZzlCT3BDRVZIR1pxR2I5TWN5MXVHVXhUClFTVENCbmpoTWZlZ0p6cXVPYWVOY0ZPSE5tbmtWRWpLTythbTBPeEhNS1lyS3ZnQnVEbzdoVnFENlBFMUd6V3AKZHdwZUV4QXZDSVJxL2pWTkdRK3FtMkRWOVNJZ3U5bmF4MktmSUtFeU50dUFFS1VpekdqL0VmRFhDM1cxMExhegpKaGNYNGw1SUFZU1o3L3JWVmpGbExWSVl0WDU1T054L1Z3SURBUUFCCi0tLS0tRU5EIFJTQSBQVUJMSUMgS0VZLS0tLS0K";
const appKey = "z4nXOeTeSnnpUpnmsV";
export type LicenseVerifyReq = {
subjectId: string;
license: string;
};
type License = {
appKey: string;
code: string;
subjectId: string;
expireTime: number;
activeTime: number;
duration: number;
version: number;
secret: string;
signature: string;
};
class LicenseHolder {
isPlus = false;
}
const holder = new LicenseHolder();
holder.isPlus = false;
class LicenseVerifier {
checked = false;
licenseReq?: LicenseVerifyReq = undefined;
async reVerify(req: LicenseVerifyReq) {
this.checked = false;
return await this.verify(req);
}
setPlus(value: boolean) {
holder.isPlus = value;
return value;
}
async verify(req: LicenseVerifyReq) {
this.licenseReq = req;
if (this.checked) {
return this.setPlus(false);
}
const license = req?.license;
if (!license) {
this.checked = true;
return this.setPlus(false);
}
const licenseJson = Buffer.from(Buffer.from(license, "hex").toString(), "base64").toString();
const json: License = JSON.parse(licenseJson);
if (json.expireTime < Date.now()) {
logger.warn("授权已过期");
return this.setPlus(false);
}
const content = `${appKey},${this.licenseReq.subjectId},${json.code},${json.secret},${json.activeTime},${json.duration},${json.expireTime},${json.version}`;
const publicKey = Buffer.from(SecreteKey, "base64").toString();
const res = this.verifySignature(content, json.signature, publicKey);
this.checked = true;
if (!res) {
logger.warn("授权校验失败");
return this.setPlus(false);
}
return this.setPlus(true);
}
verifySignature(content: string, signature: any, publicKey: string) {
const verify = createVerify("RSA-SHA256");
verify.update(content);
return verify.verify(publicKey, signature, "base64");
export function checkPlus() {
if (!isPlus()) {
throw new Error("此为专业版功能,请升级到专业版");
}
}
const verifier = new LicenseVerifier();
export function isPlus() {
return holder.isPlus;
}
export async function verify(req: LicenseVerifyReq) {
return await verifier.reVerify(req);
}

View File

@@ -1,4 +1,4 @@
import { Context, HistoryResult, Pipeline, ResultType, Runnable, RunnableMap, Stage, Step, Task } from "../dt/index.js";
import { HistoryResult, Pipeline, ResultType, Runnable, RunnableMap, Stage, Step, Task } from "../dt/index.js";
import _ from "lodash-es";
import { buildLogger } from "../utils/util.log.js";
import { Logger } from "log4js";
@@ -14,15 +14,12 @@ export type RunTrigger = {
export function NewRunHistory(obj: any) {
const history = new RunHistory(obj.id, obj.trigger, obj.pipeline);
history.context = obj.context;
history.logs = obj.logs;
history._loggers = obj.loggers;
return history;
}
export class RunHistory {
id!: string;
//运行时上下文变量
context: Context = {};
pipeline!: Pipeline;
logs: {
[runnableId: string]: string[];
@@ -44,10 +41,8 @@ export class RunHistory {
this._loggers[runnable.id] = buildLogger((text) => {
this.logs[runnable.id].push(text);
});
const input = (runnable as Step).input;
const status: HistoryResult = {
output: {},
input: _.cloneDeep(input),
status: ResultType.start,
startTime: now,
result: ResultType.start,
@@ -107,12 +102,13 @@ export class RunHistory {
log(runnable: Runnable, text: string) {
// @ts-ignore
this._loggers[runnable.id].info(`[${runnable.title}]<id:${runnable.id}> [${runnable.runnableType}]`, text);
this._loggers[runnable.id].info(`[${runnable.runnableType}] [${runnable.title}]<id:${runnable.id}> `, text);
}
logError(runnable: Runnable, e: Error) {
// @ts-ignore
this._loggers[runnable.id].error(`[${runnable.title}]<id:${runnable.id}> [${runnable.runnableType}]`, e);
const errorInfo = runnable.runnableType == "step" ? e.stack : e.message;
this._loggers[runnable.id].error(`[${runnable.runnableType}] [${runnable.title}]<id:${runnable.id}> ${errorInfo}`);
}
finally(runnable: Runnable) {
@@ -168,4 +164,16 @@ export class RunnableCollection {
item.status = undefined;
});
}
clearById(id: string) {
const runnable = this.collection[id];
if (runnable?.status) {
runnable.status.status = ResultType.none;
runnable.status.result = ResultType.none;
}
}
add(runnable: Runnable) {
this.collection[runnable.id] = runnable;
}
}

View File

@@ -118,7 +118,8 @@ export type HistoryResultGroup = {
};
};
export type HistoryResult = {
input: any;
// input: any;
inputHash?: string;
output: any;
files?: FileItem[];
/**

View File

@@ -6,7 +6,7 @@ import { IAccessService } from "../access/index.js";
import { IEmailService } from "../service/index.js";
import { IContext } from "../core/index.js";
import { AxiosInstance } from "axios";
import { logger } from "../utils/index.js";
import { ILogger, logger } from "../utils/index.js";
export enum ContextScope {
global,
@@ -17,6 +17,7 @@ export enum ContextScope {
export type TaskOutputDefine = {
title: string;
value?: any;
type?: string;
};
export type TaskInputDefine = FormItemProps;
@@ -40,6 +41,8 @@ export type PluginDefine = Registrable & {
dest: string;
type: "computed";
}[];
needPlus?: boolean;
};
export type ITaskPlugin = {
@@ -64,11 +67,15 @@ export type TaskInstanceContext = {
http: AxiosInstance;
fileStore: FileStore;
lastStatus?: Runnable;
signal: AbortSignal;
};
export abstract class AbstractTaskPlugin implements ITaskPlugin {
_result: TaskResult = { clearLastStatus: false, files: [], pipelineVars: {} };
ctx!: TaskInstanceContext;
logger!: ILogger;
accessService!: IAccessService;
clearLastStatus() {
this._result.clearLastStatus = true;
}
@@ -79,6 +86,8 @@ export abstract class AbstractTaskPlugin implements ITaskPlugin {
setCtx(ctx: TaskInstanceContext) {
this.ctx = ctx;
this.logger = ctx.logger;
this.accessService = ctx.accessService;
}
randomFileId() {

View File

@@ -4,6 +4,7 @@ export * from "./util.log.js";
export * from "./util.file.js";
export * from "./util.sp.js";
export * as promises from "./util.promise.js";
export * from "./util.hash.js";
export const utils = {
sleep,
http: request,

View File

@@ -0,0 +1,9 @@
import crypto from "crypto";
function md5(data: string) {
return crypto.createHash("md5").update(data).digest("hex");
}
export const hashUtils = {
md5,
};

View File

@@ -1,8 +1,31 @@
import axios from "axios";
// @ts-ignore
import qs from "qs";
import { logger } from "./util.log.js";
import { Logger } from "log4js";
export class HttpError extends Error {
request?: { url: string; method: string; data?: any };
response?: { data: any };
status?: number;
statusText?: string;
constructor(error: any) {
if (!error) {
return;
}
super(error.message);
this.name = error.name;
this.stack = error.stack;
this.status = error?.response?.status;
this.statusText = error?.response?.statusText;
this.request = {
url: error?.response?.config?.url,
method: error?.response?.config?.method,
data: error?.response?.config?.data,
};
this.response = {
data: error?.response?.data,
};
}
}
/**
* @description 创建请求实例
*/
@@ -12,13 +35,6 @@ export function createAxiosService({ logger }: { logger: Logger }) {
// 请求拦截
service.interceptors.request.use(
(config: any) => {
if (config.formData) {
config.data = qs.stringify(config.formData, {
arrayFormat: "indices",
allowDots: true,
}); // 序列化请求参数
delete config.formData;
}
logger.info(`http request:${config.url}method:${config.method}`);
return config;
},
@@ -50,11 +66,12 @@ export function createAxiosService({ logger }: { logger: Logger }) {
// case 505: error.message = 'HTTP版本不受支持'; break
// default: break
// }
logger.error(`请求出错url:${error?.response?.config.url},method:${error?.response?.config?.method},status:${error?.response?.status}`);
logger.info("返回数据:", JSON.stringify(error?.response?.data));
delete error.config;
delete error.response;
return Promise.reject(error);
logger.error(
`请求出错status:${error?.response?.status},statusText:${error?.response?.statusText},url:${error?.config?.url},method:${error?.config?.method}`
);
logger.error("返回数据:", JSON.stringify(error?.response?.data));
const err = new HttpError(error);
return Promise.reject(err);
}
);
return service;

View File

@@ -51,7 +51,7 @@ export type SpawnOption = {
cmd: string | string[];
onStdout?: (data: string) => void;
onStderr?: (data: string) => void;
env: any;
env?: any;
logger?: ILogger;
options?: any;
};
@@ -66,13 +66,15 @@ async function spawn(opts: SpawnOption): Promise<string> {
cmd = item;
}
}
} else {
cmd = opts.cmd;
}
log.info(`执行命令: ${cmd}`);
let stdout = "";
let stderr = "";
return safePromise((resolve, reject) => {
const ls = childProcess.spawn(cmd, {
shell: process.platform == "win32",
shell: true,
env: {
...process.env,
...opts.env,

View File

@@ -3,6 +3,14 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.24.2](https://github.com/certd/certd/compare/v1.24.1...v1.24.2) (2024-09-06)
**Note:** Version bump only for package @certd/lib-huawei
## [1.24.1](https://github.com/certd/certd/compare/v1.24.0...v1.24.1) (2024-09-02)
**Note:** Version bump only for package @certd/lib-huawei
## [1.22.1](https://github.com/certd/certd/compare/v1.22.0...v1.22.1) (2024-07-20)
**Note:** Version bump only for package @certd/lib-huawei

View File

@@ -1,7 +1,7 @@
{
"name": "@certd/lib-huawei",
"private": false,
"version": "1.22.1",
"version": "1.24.2",
"main": "./dist/bundle.js",
"module": "./dist/bundle.js",
"types": "./dist/d/index.d.ts",
@@ -16,5 +16,5 @@
"axios": "^1.7.2",
"rollup": "^3.7.4"
},
"gitHead": "47fe3d5826661f678d081ab53e67c847a3239d88"
"gitHead": "bef6b981e26a010a797734e508de6822de8564f5"
}

View File

@@ -26,7 +26,7 @@ module.exports = {
rootDir: "src",
declaration: true,
declarationDir: "dist/d",
exclude: ["./node_modules/**", "./src/**/*.vue"],
exclude: ["./node_modules/**", "./src/**/*.vue", "./src/**/*.spec.ts"],
allowSyntheticDefaultImports: true,
}),
json(),

View File

@@ -1,5 +1,5 @@
{
"compileOnSave": true,
"compileOnSave": false,
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",

View File

@@ -3,6 +3,24 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.24.2](https://github.com/certd/certd/compare/v1.24.1...v1.24.2) (2024-09-06)
**Note:** Version bump only for package @certd/lib-k8s
## [1.24.1](https://github.com/certd/certd/compare/v1.24.0...v1.24.1) (2024-09-02)
**Note:** Version bump only for package @certd/lib-k8s
# [1.24.0](https://github.com/certd/certd/compare/v1.23.1...v1.24.0) (2024-08-25)
### Performance Improvements
* 更新k8s底层api库 ([746bb9d](https://github.com/certd/certd/commit/746bb9d385e2f397daef4976eca1d4782a2f5ebd))
## [1.23.1](https://github.com/certd/certd/compare/v1.23.0...v1.23.1) (2024-08-06)
**Note:** Version bump only for package @certd/lib-k8s
## [1.22.8](https://github.com/certd/certd/compare/v1.22.7...v1.22.8) (2024-08-05)
**Note:** Version bump only for package @certd/lib-k8s

View File

@@ -1,7 +1,7 @@
{
"name": "@certd/lib-k8s",
"private": false,
"version": "1.22.8",
"version": "1.24.2",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -13,11 +13,10 @@
"preview": "vite preview"
},
"dependencies": {
"kubernetes-client": "^9.0.0",
"shelljs": "^0.8.5"
"@kubernetes/client-node": "0.21.0"
},
"devDependencies": {
"@certd/pipeline": "^1.22.8",
"@certd/pipeline": "^1.24.2",
"@rollup/plugin-commonjs": "^23.0.4",
"@rollup/plugin-json": "^6.0.0",
"@rollup/plugin-node-resolve": "^15.0.1",
@@ -38,5 +37,5 @@
"tslib": "^2.5.2",
"typescript": "^4.8.4"
},
"gitHead": "e5da46cfc31b2e30a4903bcb2251b1851265ef41"
"gitHead": "bef6b981e26a010a797734e508de6822de8564f5"
}

View File

@@ -1,30 +1,40 @@
import kubernetesClient from 'kubernetes-client';
//@ts-ignore
import { KubeConfig, CoreV1Api, V1Secret, NetworkingV1Api, V1Ingress } from '@kubernetes/client-node';
import dns from 'dns';
import { logger } from '@certd/pipeline';
import { ILogger } from '@certd/pipeline';
//@ts-ignore
const { KubeConfig, Client, Request } = kubernetesClient;
export class K8sClient {
export type K8sClientOpts = {
kubeConfigStr: string;
lookup!: any;
client!: any;
constructor(kubeConfigStr: string) {
this.kubeConfigStr = kubeConfigStr;
logger: ILogger;
//{ [domain]:{ip:'xxx.xx.xxx'} }
//暂时没用
lookup?: any;
};
export class K8sClient {
kubeconfig!: KubeConfig;
kubeConfigStr: string;
lookup!: (hostnameReq: any, options: any, callback: any) => void;
client!: CoreV1Api;
logger: ILogger;
constructor(opts: K8sClientOpts) {
this.kubeConfigStr = opts.kubeConfigStr;
this.logger = opts.logger;
this.setLookup(opts.lookup);
this.init();
}
init() {
const kubeconfig = new KubeConfig();
kubeconfig.loadFromString(this.kubeConfigStr);
const reqOpts = { kubeconfig, request: {} } as any;
if (this.lookup) {
reqOpts.request.lookup = this.lookup;
}
this.kubeconfig = kubeconfig;
this.client = kubeconfig.makeApiClient(CoreV1Api);
const backend = new Request(reqOpts);
this.client = new Client({ backend, version: '1.13' });
// const reqOpts = { kubeconfig, request: {} } as any;
// if (this.lookup) {
// reqOpts.request.lookup = this.lookup;
// }
//
// const backend = new Request(reqOpts);
// this.client = new Client({ backend, version: '1.13' });
}
/**
@@ -32,16 +42,18 @@ export class K8sClient {
* @param localRecords { [domain]:{ip:'xxx.xx.xxx'} }
*/
setLookup(localRecords: { [key: string]: { ip: string } }) {
if (localRecords == null) {
return;
}
this.lookup = (hostnameReq: any, options: any, callback: any) => {
logger.info('custom lookup', hostnameReq, localRecords);
this.logger.info('custom lookup', hostnameReq, localRecords);
if (localRecords[hostnameReq]) {
logger.info('local record', hostnameReq, localRecords[hostnameReq]);
this.logger.info('local record', hostnameReq, localRecords[hostnameReq]);
callback(null, localRecords[hostnameReq].ip, 4);
} else {
dns.lookup(hostnameReq, options, callback);
}
};
this.init();
}
/**
@@ -49,9 +61,9 @@ export class K8sClient {
* @param opts = {namespace:default}
* @returns secretsList
*/
async getSecret(opts: { namespace: string }) {
async getSecrets(opts: { namespace: string }) {
const namespace = opts.namespace || 'default';
return await this.client.api.v1.namespaces(namespace).secrets.get();
return await this.client.listNamespacedSecret(namespace);
}
/**
@@ -59,59 +71,61 @@ export class K8sClient {
* @param opts {namespace:default, body:yamlStr}
* @returns {Promise<*>}
*/
async createSecret(opts: any) {
async createSecret(opts: { namespace: string; body: V1Secret }) {
const namespace = opts.namespace || 'default';
const created = await this.client.api.v1.namespaces(namespace).secrets.post({
body: opts.body,
});
logger.info('new secrets:', created);
return created;
const created = await this.client.createNamespacedSecret(namespace, opts.body);
this.logger.info('new secrets:', created.body);
return created.body;
}
async updateSecret(opts: any) {
// async updateSecret(opts: any) {
// const namespace = opts.namespace || 'default';
// const secretName = opts.secretName;
// if (secretName == null) {
// throw new Error('secretName 不能为空');
// }
// return await this.client.replaceNamespacedSecret(secretName, namespace, opts.body);
// }
async patchSecret(opts: { namespace: string; secretName: string; body: V1Secret }) {
const namespace = opts.namespace || 'default';
const secretName = opts.secretName;
if (secretName == null) {
throw new Error('secretName 不能为空');
}
return await this.client.api.v1.namespaces(namespace).secrets(secretName).put({
body: opts.body,
});
const res = await this.client.patchNamespacedSecret(secretName, namespace, opts.body);
this.logger.info('secret patched:', res.body);
return res.body;
}
async patchSecret(opts: any) {
async getIngressList(opts: { namespace: string }) {
const namespace = opts.namespace || 'default';
const secretName = opts.secretName;
if (secretName == null) {
throw new Error('secretName 不能为空');
}
return await this.client.api.v1.namespaces(namespace).secrets(secretName).patch({
body: opts.body,
});
const client = this.kubeconfig.makeApiClient(NetworkingV1Api);
const res = await client.listNamespacedIngress(namespace);
this.logger.info('ingress list get:', res.body);
return res.body;
}
async getIngressList(opts: any) {
const namespace = opts.namespace || 'default';
return await this.client.apis.extensions.v1beta1.namespaces(namespace).ingresses.get();
}
// async getIngress(opts: { namespace: string; ingressName: string }) {
// const namespace = opts.namespace || 'default';
// const ingressName = opts.ingressName;
// if (!ingressName) {
// throw new Error('ingressName 不能为空');
// }
// const client = this.kubeconfig.makeApiClient(NetworkingV1Api);
// const res = await client.listNamespacedIngress();
// return await this.client.apis.extensions.v1beta1.namespaces(namespace).ingresses(ingressName).get();
// }
async getIngress(opts: any) {
async patchIngress(opts: { namespace: string; ingressName: string; body: V1Ingress }) {
const namespace = opts.namespace || 'default';
const ingressName = opts.ingressName;
if (!ingressName) {
throw new Error('ingressName 不能为空');
}
return await this.client.apis.extensions.v1beta1.namespaces(namespace).ingresses(ingressName).get();
}
async patchIngress(opts: any) {
const namespace = opts.namespace || 'default';
const ingressName = opts.ingressName;
if (!ingressName) {
throw new Error('ingressName 不能为空');
}
return await this.client.apis.extensions.v1beta1.namespaces(namespace).ingresses(ingressName).patch({
body: opts.body,
});
const client = this.kubeconfig.makeApiClient(NetworkingV1Api);
const res = await client.patchNamespacedIngress(ingressName, namespace, opts.body);
this.logger.info('ingress patched:', res.body);
return res;
}
}

View File

@@ -3,6 +3,45 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.24.2](https://github.com/certd/certd/compare/v1.24.1...v1.24.2) (2024-09-06)
### Performance Improvements
* 任务配置不需要的字段可以自动隐藏 ([192d9dc](https://github.com/certd/certd/commit/192d9dc7e36737d684c769f255f407c28b1152ac))
* 修复windows下无法执行第二条命令的bug ([d5bfcdb](https://github.com/certd/certd/commit/d5bfcdb6de1dcc1702155442e2e00237d0bbb6e5))
* 优化跳过处理逻辑 ([b80210f](https://github.com/certd/certd/commit/b80210f24bf5db1c958d06ab27c9e5d3db452eda))
* 支持pfx、der ([fbeaed2](https://github.com/certd/certd/commit/fbeaed203519f59b6d9396c4e8953353ccb5e723))
## [1.24.1](https://github.com/certd/certd/compare/v1.24.0...v1.24.1) (2024-09-02)
### Performance Improvements
* 授权配置支持加密 ([42a56b5](https://github.com/certd/certd/commit/42a56b581d754c3e5f9838179d19ab0d004ef2eb))
# [1.24.0](https://github.com/certd/certd/compare/v1.23.1...v1.24.0) (2024-08-25)
### Bug Fixes
* 修复成功后跳过之后丢失腾讯云证书id的bug ([37eb762](https://github.com/certd/certd/commit/37eb762afe25c5896b75dee25f32809f8426e7b7))
* 修复创建流水线后立即运行时报no id错误的bug ([17ead54](https://github.com/certd/certd/commit/17ead547aab25333603980304aa3aad3db1f73d5))
* 修复使用代理的情况下申请证书失败的bug ([95122e2](https://github.com/certd/certd/commit/95122e28609333f4df55c266e5434897954c0fb3))
### Features
* 支持ECC类型 ([a7424e0](https://github.com/certd/certd/commit/a7424e02f5c7e02ac1688791040785920ce67473))
### Performance Improvements
* 更新k8s底层api库 ([746bb9d](https://github.com/certd/certd/commit/746bb9d385e2f397daef4976eca1d4782a2f5ebd))
* 优化成功后跳过的提示 ([7b451bb](https://github.com/certd/certd/commit/7b451bbf6e6337507f4627b5a845f5bd96ab4f7b))
* 优化证书申请成功率 ([968c469](https://github.com/certd/certd/commit/968c4690a07f69c08dcb3d3a494da4e319627345))
## [1.23.1](https://github.com/certd/certd/compare/v1.23.0...v1.23.1) (2024-08-06)
### Performance Improvements
* 优化插件字段的default value ([24c7be2](https://github.com/certd/certd/commit/24c7be2c9cb39c14f7a97b674127c88033280b02))
## [1.22.8](https://github.com/certd/certd/compare/v1.22.7...v1.22.8) (2024-08-05)
**Note:** Version bump only for package @certd/plugin-cert

View File

@@ -1,7 +1,7 @@
{
"name": "@certd/plugin-cert",
"private": false,
"version": "1.22.8",
"version": "1.24.2",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -13,8 +13,8 @@
"preview": "vite preview"
},
"dependencies": {
"@certd/acme-client": "^1.22.6",
"@certd/pipeline": "^1.22.8",
"@certd/acme-client": "^1.24.2",
"@certd/pipeline": "^1.24.2",
"jszip": "^3.10.1",
"node-forge": "^0.10.0",
"psl": "^1.9.0"
@@ -53,5 +53,5 @@
"vite": "^3.1.0",
"vue-tsc": "^0.38.9"
},
"gitHead": "e5da46cfc31b2e30a4903bcb2251b1851265ef41"
"gitHead": "bef6b981e26a010a797734e508de6822de8564f5"
}

View File

@@ -13,6 +13,7 @@ export class EabAccess {
},
helper: "EAB KID",
required: true,
encrypt: true,
})
kid = "";
@AccessInput({
@@ -22,6 +23,7 @@ export class EabAccess {
},
helper: "EAB HMAC Key",
required: true,
encrypt: true,
})
hmacKey = "";
}

View File

@@ -7,13 +7,16 @@ import { IContext } from "@certd/pipeline";
import { IDnsProvider } from "../../dns-provider/index.js";
import psl from "psl";
import { ClientExternalAccountBindingOptions, UrlMapping } from "@certd/acme-client";
import { utils } from "@certd/pipeline";
export type CertInfo = {
crt: string;
key: string;
csr: string;
pfx?: string;
der?: string;
};
export type SSLProvider = "letsencrypt" | "buypass" | "zerossl";
export type SSLProvider = "letsencrypt" | "google" | "zerossl";
export type PrivateKeyType = "rsa_1024" | "rsa_2048" | "rsa_3072" | "rsa_4096" | "ec_256" | "ec_384" | "ec_521";
type AcmeServiceOptions = {
userContext: IContext;
logger: Logger;
@@ -21,6 +24,8 @@ type AcmeServiceOptions = {
eab?: ClientExternalAccountBindingOptions;
skipLocalVerify?: boolean;
useMappingProxy?: boolean;
privateKeyType?: PrivateKeyType;
signal?: AbortSignal;
};
export class AcmeService {
@@ -42,8 +47,20 @@ export class AcmeService {
});
}
async getAccountConfig(email: string): Promise<any> {
return (await this.userContext.getObj(this.buildAccountKey(email))) || {};
async getAccountConfig(email: string, urlMapping: UrlMapping): Promise<any> {
const conf = (await this.userContext.getObj(this.buildAccountKey(email))) || {};
if (urlMapping && urlMapping.mappings) {
for (const key in urlMapping.mappings) {
if (Object.prototype.hasOwnProperty.call(urlMapping.mappings, key)) {
const element = urlMapping.mappings[key];
if (conf.accountUrl?.indexOf(element) > -1) {
//如果用了代理url要替换回去
conf.accountUrl = conf.accountUrl.replace(element, key);
}
}
}
}
return conf;
}
buildAccountKey(email: string) {
@@ -55,10 +72,18 @@ export class AcmeService {
}
async getAcmeClient(email: string, isTest = false): Promise<acme.Client> {
const conf = await this.getAccountConfig(email);
const urlMapping: UrlMapping = {
enabled: false,
mappings: {
"acme-v02.api.letsencrypt.org": "letsencrypt.proxy.handsfree.work",
"dv.acme-v02.api.pki.goog": "google.proxy.handsfree.work",
},
};
const conf = await this.getAccountConfig(email, urlMapping);
if (conf.key == null) {
conf.key = await this.createNewKey();
await this.saveAccountConfig(email, conf);
this.logger.info(`创建新的Accountkey:${email}`);
}
let directoryUrl = "";
if (isTest) {
@@ -66,22 +91,26 @@ export class AcmeService {
} else {
directoryUrl = acme.directory[this.sslProvider].production;
}
const urlMapping: UrlMapping = { enabled: false, mappings: {} };
if (this.options.useMappingProxy) {
urlMapping.enabled = true;
urlMapping.mappings = {
"acme-v02.api.letsencrypt.org": "letsencrypt.proxy.handsfree.work",
};
} else {
//测试directory是否可以访问
const isOk = await this.testDirectory(directoryUrl);
if (!isOk) {
this.logger.info("测试访问失败,自动使用代理");
urlMapping.enabled = true;
}
}
const client = new acme.Client({
directoryUrl: directoryUrl,
accountKey: conf.key,
accountUrl: conf.accountUrl,
externalAccountBinding: this.eab,
backoffAttempts: 30,
backoffAttempts: 15,
backoffMin: 5000,
backoffMax: 10000,
urlMapping,
signal: this.options.signal,
});
if (conf.accountUrl == null) {
@@ -98,7 +127,7 @@ export class AcmeService {
}
async createNewKey() {
const key = await acme.forge.createPrivateKey();
const key = await acme.crypto.createPrivateKey(2048);
return key.toString();
}
@@ -193,18 +222,41 @@ export class AcmeService {
}
}
async order(options: { email: string; domains: string | string[]; dnsProvider: any; csrInfo: any; isTest?: boolean }) {
async order(options: {
email: string;
domains: string | string[];
dnsProvider: any;
csrInfo: any;
isTest?: boolean;
privateKeyType?: string;
}): Promise<CertInfo> {
const { email, isTest, domains, csrInfo, dnsProvider } = options;
const client: acme.Client = await this.getAcmeClient(email, isTest);
/* Create CSR */
const { commonName, altNames } = this.buildCommonNameByDomains(domains);
const [key, csr] = await acme.forge.createCsr({
commonName,
...csrInfo,
altNames,
});
let privateKey = null;
const privateKeyType = options.privateKeyType || "rsa_2048";
const privateKeyArr = privateKeyType.split("_");
const type = privateKeyArr[0];
let size = 2048;
if (privateKeyArr.length > 1) {
size = parseInt(privateKeyArr[1]);
}
if (type == "ec") {
const name: any = "P-" + size;
privateKey = await acme.crypto.createPrivateEcdsaKey(name);
} else {
privateKey = await acme.crypto.createPrivateRsaKey(size);
}
const [key, csr] = await acme.crypto.createCsr(
{
commonName,
...csrInfo,
altNames,
},
privateKey
);
if (dnsProvider == null) {
throw new Error("dnsProvider 不能为空");
}
@@ -221,6 +273,7 @@ export class AcmeService {
challengeRemoveFn: async (authz: acme.Authorization, challenge: Challenge, keyAuthorization: string, recordItem: any): Promise<any> => {
return await this.challengeRemoveFn(authz, challenge, keyAuthorization, recordItem, dnsProvider);
},
signal: this.options.signal,
});
const cert: CertInfo = {
@@ -255,4 +308,19 @@ export class AcmeService {
altNames,
};
}
private async testDirectory(directoryUrl: string) {
try {
await utils.http({
url: directoryUrl,
method: "GET",
timeout: 10000,
});
} catch (e) {
this.logger.error(`${directoryUrl},测试访问失败`, e.stack);
return false;
}
this.logger.info(`${directoryUrl},测试访问成功`);
return true;
}
}

View File

@@ -1,9 +1,10 @@
import { AbstractTaskPlugin, HttpClient, IAccessService, IContext, Step, TaskInput, TaskOutput } from "@certd/pipeline";
import { AbstractTaskPlugin, HttpClient, IContext, Step, TaskInput, TaskOutput } from "@certd/pipeline";
import dayjs from "dayjs";
import type { CertInfo } from "./acme.js";
import { Logger } from "log4js";
import { CertReader } from "./cert-reader.js";
import JSZip from "jszip";
import { CertConverter } from "./convert.js";
import fs from "fs";
export { CertReader };
export type { CertInfo };
@@ -16,6 +17,7 @@ export abstract class CertApplyBasePlugin extends AbstractTaskPlugin {
vModel: "value",
mode: "tags",
open: false,
tokenSeparators: [",", " ", "", "、", "|"],
},
required: true,
col: {
@@ -26,7 +28,7 @@ export abstract class CertApplyBasePlugin extends AbstractTaskPlugin {
"1、支持通配符域名例如 *.foo.com、foo.com、*.test.handsfree.work\n" +
"2、支持多个域名、多个子域名、多个通配符域名打到一个证书上域名必须是在同一个DNS提供商解析\n" +
"3、多级子域名要分成多个域名输入*.foo.com的证书不能用于xxx.yyy.foo.com、foo.com\n" +
"4、输入一个回车之后,再输入下一个",
"4、输入一个空格之后,再输入下一个",
})
domains!: string[];
@@ -42,6 +44,18 @@ export abstract class CertApplyBasePlugin extends AbstractTaskPlugin {
})
email!: string;
@TaskInput({
title: "PFX密码",
component: {
name: "a-input-password",
vModel: "value",
},
required: false,
order: 100,
helper: "PFX格式证书是否需要加密",
})
pfxPassword!: string;
@TaskInput({
title: "更新天数",
value: 20,
@@ -78,22 +92,13 @@ export abstract class CertApplyBasePlugin extends AbstractTaskPlugin {
})
successNotify = true;
@TaskInput({
title: "配置说明",
order: 9999,
helper: "运行策略请选择总是运行,其他证书部署任务请选择成功后跳过;当证书快到期前将会自动重新申请证书,然后会清空后续任务的成功状态,部署任务将会重新运行",
})
intro!: string;
// @TaskInput({
// title: "CsrInfo",
// helper: "暂时没有用",
// })
csrInfo!: string;
logger!: Logger;
userContext!: IContext;
accessService!: IAccessService;
http!: HttpClient;
lastStatus!: Step;
@@ -103,8 +108,6 @@ export abstract class CertApplyBasePlugin extends AbstractTaskPlugin {
cert?: CertInfo;
async onInstance() {
this.accessService = this.ctx.accessService;
this.logger = this.ctx.logger;
this.userContext = this.ctx.userContext;
this.http = this.ctx.http;
this.lastStatus = this.ctx.lastStatus as Step;
@@ -138,24 +141,46 @@ export abstract class CertApplyBasePlugin extends AbstractTaskPlugin {
const cert: CertInfo = certReader.toCertInfo();
this.cert = cert;
this._result.pipelineVars.certExpiresTime = dayjs(certReader.detail.validity.notAfter).valueOf();
this._result.pipelineVars.certExpiresTime = dayjs(certReader.detail.notAfter).valueOf();
if (cert.pfx == null || cert.der == null) {
try {
const converter = new CertConverter({ logger: this.logger });
const res = await converter.convert({
cert,
pfxPassword: this.pfxPassword,
});
const pfxBuffer = fs.readFileSync(res.pfxPath);
cert.pfx = pfxBuffer.toString("base64");
const derBuffer = fs.readFileSync(res.derPath);
cert.der = derBuffer.toString("base64");
this.logger.info("转换证书格式成功");
isNew = true;
} catch (e) {
this.logger.error("转换证书格式失败", e);
}
}
if (isNew) {
const applyTime = dayjs(certReader.detail.validity.notBefore).format("YYYYMMDD_HHmmss");
await this.zipCert(cert, applyTime);
const zipFileName = certReader.buildCertFileName("zip", certReader.detail.notBefore);
await this.zipCert(cert, zipFileName);
} else {
this.extendsFiles();
}
// thi
// s.logger.info(JSON.stringify(certReader.detail));
}
async zipCert(cert: CertInfo, applyTime: string) {
async zipCert(cert: CertInfo, filename: string) {
const zip = new JSZip();
zip.file("cert.crt", cert.crt);
zip.file("cert.key", cert.key);
const domain_name = this.domains[0].replace(".", "_").replace("*", "_");
const filename = `cert_${domain_name}_${applyTime}.zip`;
if (cert.pfx) {
zip.file("cert.pfx", Buffer.from(cert.pfx, "base64"));
}
if (cert.der) {
zip.file("cert.der", Buffer.from(cert.der, "base64"));
}
const content = await zip.generateAsync({ type: "nodebuffer" });
this.saveFile(filename, content);
this.logger.info(`已保存文件:${filename}`);

View File

@@ -1,9 +1,16 @@
import { CertInfo } from "./acme.js";
import fs from "fs";
import os from "os";
import forge from "node-forge";
import path from "path";
export class CertReader implements CertInfo {
import { crypto } from "@certd/acme-client";
import { ILogger } from "@certd/pipeline";
import dayjs from "dayjs";
export type CertReaderHandleContext = { reader: CertReader; tmpCrtPath: string; tmpKeyPath: string; tmpPfxPath?: string; tmpDerPath?: string };
export type CertReaderHandle = (ctx: CertReaderHandleContext) => Promise<void>;
export type HandleOpts = { logger: ILogger; handle: CertReaderHandle };
export class CertReader {
cert: CertInfo;
crt: string;
key: string;
csr: string;
@@ -11,31 +18,31 @@ export class CertReader implements CertInfo {
detail: any;
expires: number;
constructor(certInfo: CertInfo) {
this.cert = certInfo;
this.crt = certInfo.crt;
this.key = certInfo.key;
this.csr = certInfo.csr;
const { detail, expires } = this.getCrtDetail(this.crt);
const { detail, expires } = this.getCrtDetail(this.cert.crt);
this.detail = detail;
this.expires = expires.getTime();
}
toCertInfo(): CertInfo {
return {
crt: this.crt,
key: this.key,
csr: this.csr,
};
return this.cert;
}
getCrtDetail(crt: string) {
const pki = forge.pki;
const detail = pki.certificateFromPem(crt.toString());
const expires = detail.validity.notAfter;
getCrtDetail(crt: string = this.cert.crt) {
const detail = crypto.readCertificateInfo(crt.toString());
const expires = detail.notAfter;
return { detail, expires };
}
saveToFile(type: "crt" | "key", filepath?: string) {
saveToFile(type: "crt" | "key" | "pfx" | "der", filepath?: string) {
if (!this.cert[type]) {
return;
}
if (filepath == null) {
//写入临时目录
filepath = path.join(os.tmpdir(), "/certd/tmp/", Math.floor(Math.random() * 1000000) + "", `cert.${type}`);
@@ -45,8 +52,50 @@ export class CertReader implements CertInfo {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(filepath, this[type]);
if (type === "crt" || type === "key") {
fs.writeFileSync(filepath, this.cert[type]);
} else {
fs.writeFileSync(filepath, Buffer.from(this.cert[type], "base64"));
}
return filepath;
}
async readCertFile(opts: HandleOpts) {
const logger = opts.logger;
logger.info("将证书写入本地缓存文件");
const tmpCrtPath = this.saveToFile("crt");
const tmpKeyPath = this.saveToFile("key");
const tmpPfxPath = this.saveToFile("pfx");
const tmpDerPath = this.saveToFile("der");
logger.info("本地文件写入成功");
try {
await opts.handle({
reader: this,
tmpCrtPath: tmpCrtPath,
tmpKeyPath: tmpKeyPath,
tmpPfxPath: tmpPfxPath,
tmpDerPath: tmpDerPath,
});
} finally {
//删除临时文件
logger.info("删除临时文件");
function removeFile(filepath?: string) {
if (filepath) {
fs.unlinkSync(filepath);
}
}
removeFile(tmpCrtPath);
removeFile(tmpKeyPath);
removeFile(tmpPfxPath);
removeFile(tmpDerPath);
}
}
buildCertFileName(suffix: string, applyTime: number, prefix = "cert") {
const detail = this.getCrtDetail();
let domain = detail.detail.domains.commonName;
domain = domain.replace(".", "_").replace("*", "_");
const timeStr = dayjs(applyTime).format("YYYYMMDDHHmmss");
return `${prefix}_${domain}_${timeStr}.${suffix}`;
}
}

View File

@@ -0,0 +1,91 @@
import { ILogger, sp } from "@certd/pipeline";
import type { CertInfo } from "../cert-plugin/acme.js";
import { CertReader, CertReaderHandleContext } from "../cert-plugin/cert-reader.js";
import path from "path";
import os from "os";
import fs from "fs";
export { CertReader };
export type { CertInfo };
export class CertConverter {
logger: ILogger;
constructor(opts: { logger: ILogger }) {
this.logger = opts.logger;
}
async convert(opts: { cert: CertInfo; pfxPassword: string }): Promise<{
pfxPath: string;
derPath: string;
}> {
const certReader = new CertReader(opts.cert);
let pfxPath: string;
let derPath: string;
const handle = async (opts: CertReaderHandleContext) => {
// 调用openssl 转pfx
pfxPath = await this.convertPfx(opts);
// 转der
derPath = await this.convertDer(opts);
};
await certReader.readCertFile({ logger: this.logger, handle });
return {
pfxPath,
derPath,
};
}
async exec(cmd: string) {
await sp.spawn({
cmd: cmd,
logger: this.logger,
});
}
private async convertPfx(opts: CertReaderHandleContext, pfxPassword?: string) {
const { tmpCrtPath, tmpKeyPath } = opts;
const pfxPath = path.join(os.tmpdir(), "/certd/tmp/", Math.floor(Math.random() * 1000000) + "", "cert.pfx");
const dir = path.dirname(pfxPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
let passwordArg = "-passout pass:";
if (pfxPassword) {
passwordArg = `-password pass:${pfxPassword}`;
}
await this.exec(`openssl pkcs12 -export -out ${pfxPath} -inkey ${tmpKeyPath} -in ${tmpCrtPath} ${passwordArg}`);
return pfxPath;
// const fileBuffer = fs.readFileSync(pfxPath);
// this.pfxCert = fileBuffer.toString("base64");
//
// const applyTime = new Date().getTime();
// const filename = reader.buildCertFileName("pfx", applyTime);
// this.saveFile(filename, fileBuffer);
}
private async convertDer(opts: CertReaderHandleContext) {
const { tmpCrtPath } = opts;
const derPath = path.join(os.tmpdir(), "/certd/tmp/", Math.floor(Math.random() * 1000000) + "", `cert.der`);
const dir = path.dirname(derPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
await this.exec(`openssl x509 -outform der -in ${tmpCrtPath} -out ${derPath}`);
return derPath;
// const fileBuffer = fs.readFileSync(derPath);
// this.derCert = fileBuffer.toString("base64");
//
// const applyTime = new Date().getTime();
// const filename = reader.buildCertFileName("der", applyTime);
// this.saveFile(filename, fileBuffer);
}
}

View File

@@ -1,13 +1,13 @@
import { Decorator, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import type { CertInfo, SSLProvider } from "./acme.js";
import type { CertInfo, PrivateKeyType, SSLProvider } from "./acme.js";
import { AcmeService } from "./acme.js";
import _ from "lodash-es";
import { DnsProviderContext, DnsProviderDefine, dnsProviderRegistry } from "../../dns-provider/index.js";
import { CertReader } from "./cert-reader.js";
import { CertApplyBasePlugin } from "./base.js";
export { CertReader };
export type { CertInfo };
export * from "./cert-reader.js";
@IsTaskPlugin({
name: "CertApply",
@@ -27,17 +27,17 @@ export type { CertInfo };
export class CertApplyPlugin extends CertApplyBasePlugin {
@TaskInput({
title: "证书提供商",
default: "letsencrypt",
value: "letsencrypt",
component: {
name: "a-select",
vModel: "value",
options: [
{ value: "letsencrypt", label: "Let's Encrypt" },
// { value: "letsencrypt-proxy", label: "Let's Encrypt代理letsencrypt.org无法访问时使用" },
// { value: "buypass", label: "Buypass" },
{ value: "google", label: "Google" },
{ value: "zerossl", label: "ZeroSSL" },
],
},
helper: "Let's Encrypt最简单如果使用ZeroSSL、google证书需要提供EAB授权",
required: true,
})
sslProvider!: SSLProvider;
@@ -49,17 +49,47 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
type: "eab",
},
maybeNeed: true,
helper: "如果使用ZeroSSL证书需要提供EAB授权 请前往 https://app.zerossl.com/developer 生成 'EAB Credentials for ACME Clients' ",
required: true,
helper:
"需要提供EAB授权\nZeroSSL请前往[zerossl开发者中心](https://app.zerossl.com/developer),生成 'EAB Credentials' \n Google请查看[google获取eab帮助文档](https://github.com/certd/certd/blob/v2/doc/google/google.md)",
mergeScript: `
return {
show: ctx.compute(({form})=>{
return form.sslProvider === 'zerossl' || form.sslProvider === 'google'
})
}
`,
})
eabAccessId!: number;
@TaskInput({
title: "加密算法",
value: "rsa_2048",
component: {
name: "a-select",
vModel: "value",
options: [
{ value: "rsa_1024", label: "RSA 1024" },
{ value: "rsa_2048", label: "RSA 2048" },
{ value: "rsa_3072", label: "RSA 3072" },
{ value: "rsa_4096", label: "RSA 4096" },
{ value: "ec_256", label: "EC 256" },
{ value: "ec_384", label: "EC 384" },
// { value: "ec_521", label: "EC 521" },
],
},
required: true,
})
privateKeyType!: PrivateKeyType;
@TaskInput({
title: "DNS提供商",
component: {
name: "pi-dns-provider-selector",
},
required: true,
helper: "请选择dns解析提供商",
helper:
"请选择dns解析提供商您的域名是在哪里注册的或者域名的dns解析服务器属于哪个平台\n如果这里没有您需要的dns解析提供商您需要将域名解析服务器设置成上面的任意一个提供商",
})
dnsProviderType!: string;
@@ -70,30 +100,31 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
},
required: true,
helper: "请选择dns解析提供商授权",
reference: [
{
src: "form.dnsProviderType",
dest: "component.type",
type: "computed",
},
],
mergeScript: `return {
component:{
type: ctx.compute(({form})=>{
return form.dnsProviderType
})
}
}
`,
})
dnsProviderAccess!: string;
@TaskInput({
title: "使用代理",
default: false,
value: false,
component: {
name: "a-switch",
vModel: "checked",
},
helper: "如果acme-v02.api.letsencrypt.org被墙无法连接访问,请尝试开启此选项",
helper: "如果acme-v02.api.letsencrypt.org或dv.acme-v02.api.pki.goog被墙无法访问,请尝试开启此选项",
})
useProxy = false;
@TaskInput({
title: "跳过本地校验DNS",
default: false,
value: false,
component: {
name: "a-switch",
vModel: "checked",
@@ -116,6 +147,7 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
eab,
skipLocalVerify: this.skipLocalVerify,
useMappingProxy: this.useProxy,
privateKeyType: this.privateKeyType,
});
}
@@ -156,6 +188,7 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
dnsProvider,
csrInfo,
isTest: false,
privateKeyType: this.privateKeyType,
});
const certInfo = this.formatCerts(cert);

View File

@@ -97,7 +97,7 @@ export class CertApplyLegoPlugin extends CertApplyBasePlugin {
this.http = this.ctx.http;
this.lastStatus = this.ctx.lastStatus as Step;
if (this.legoEabAccessId) {
this.eab = await this.ctx.accessService.getById(this.legoEabAccessId);
this.eab = await this.accessService.getById(this.legoEabAccessId);
}
}
async onInit(): Promise<void> {}

19
packages/ui/.dockerignore Normal file
View File

@@ -0,0 +1,19 @@
logs/
npm-debug.log
yarn-error.log
node_modules/
package-lock.json
yarn.lock
coverage/
!dist/
.idea/
run/
.DS_Store
*.sw*
*.un~
.tsbuildinfo
.tsbuildinfo.*
/data/db.sqlite
*/node_modules
certd-server/tools/windows/

View File

@@ -2,7 +2,7 @@ FROM node:20-alpine AS builder
EXPOSE 7001
WORKDIR /workspace/
COPY . /workspace/
RUN npm install -g pnpm@8.15.7
RUN npm install -g pnpm
#RUN cd /workspace/certd-client && pnpm install && npm run build
RUN cp /workspace/certd-client/dist/* /workspace/certd-server/public/ -rf
@@ -10,6 +10,7 @@ RUN cd /workspace/certd-server && pnpm install && npm run build-on-docker
FROM node:20-alpine
RUN apk add --no-cache openssl
WORKDIR /app/
COPY --from=builder /workspace/certd-server/ /app/
RUN chmod +x /app/tools/linux/*

View File

@@ -1,463 +0,0 @@
/** @type {import('dependency-cruiser').IConfiguration} */
module.exports = {
forbidden: [
/* rules from the 'recommended' preset: */
{
name: 'no-circular',
severity: 'warn',
comment:
'This dependency is part of a circular relationship. You might want to revise ' +
'your solution (i.e. use dependency inversion, make sure the modules have a single responsibility) ',
from: {},
to: {
circular: true
}
},
{
name: 'no-orphans',
comment:
"This is an orphan module - it's likely not used (anymore?). Either use it or " +
"remove it. If it's logical this module is an orphan (i.e. it's a config file), " +
"add an exception for it in your dependency-cruiser configuration. By default " +
"this rule does not scrutinize dot-files (e.g. .eslintrc.js), TypeScript declaration " +
"files (.d.ts), tsconfig.json and some of the babel and webpack configs.",
severity: 'warn',
from: {
orphan: true,
pathNot: [
'(^|/)\\.[^/]+\\.(js|cjs|mjs|ts|json)$', // dot files
'\\.d\\.ts$', // TypeScript declaration files
'(^|/)tsconfig\\.json$', // TypeScript config
'(^|/)(babel|webpack)\\.config\\.(js|cjs|mjs|ts|json)$' // other configs
]
},
to: {},
},
{
name: 'no-deprecated-core',
comment:
'A module depends on a node core module that has been deprecated. Find an alternative - these are ' +
"bound to exist - node doesn't deprecate lightly.",
severity: 'warn',
from: {},
to: {
dependencyTypes: [
'core'
],
path: [
'^(v8\/tools\/codemap)$',
'^(v8\/tools\/consarray)$',
'^(v8\/tools\/csvparser)$',
'^(v8\/tools\/logreader)$',
'^(v8\/tools\/profile_view)$',
'^(v8\/tools\/profile)$',
'^(v8\/tools\/SourceMap)$',
'^(v8\/tools\/splaytree)$',
'^(v8\/tools\/tickprocessor-driver)$',
'^(v8\/tools\/tickprocessor)$',
'^(node-inspect\/lib\/_inspect)$',
'^(node-inspect\/lib\/internal\/inspect_client)$',
'^(node-inspect\/lib\/internal\/inspect_repl)$',
'^(async_hooks)$',
'^(punycode)$',
'^(domain)$',
'^(constants)$',
'^(sys)$',
'^(_linklist)$',
'^(_stream_wrap)$'
],
}
},
{
name: 'not-to-deprecated',
comment:
'This module uses a (version of an) npm module that has been deprecated. Either upgrade to a later ' +
'version of that module, or find an alternative. Deprecated modules are a security risk.',
severity: 'warn',
from: {},
to: {
dependencyTypes: [
'deprecated'
]
}
},
{
name: 'no-non-package-json',
severity: 'error',
comment:
"This module depends on an npm package that isn't in the 'dependencies' section of your package.json. " +
"That's problematic as the package either (1) won't be available on live (2 - worse) will be " +
"available on live with an non-guaranteed version. Fix it by adding the package to the dependencies " +
"in your package.json.",
from: {},
to: {
dependencyTypes: [
'npm-no-pkg',
'npm-unknown'
]
}
},
{
name: 'not-to-unresolvable',
comment:
"This module depends on a module that cannot be found ('resolved to disk'). If it's an npm " +
'module: add it to your package.json. In all other cases you likely already know what to do.',
severity: 'error',
from: {},
to: {
couldNotResolve: true
}
},
{
name: 'no-duplicate-dep-types',
comment:
"Likely this module depends on an external ('npm') package that occurs more than once " +
"in your package.json i.e. bot as a devDependencies and in dependencies. This will cause " +
"maintenance problems later on.",
severity: 'warn',
from: {},
to: {
moreThanOneDependencyType: true,
// as it's pretty common to have a type import be a type only import
// _and_ (e.g.) a devDependency - don't consider type-only dependency
// types for this rule
dependencyTypesNot: ["type-only"]
}
},
/* rules you might want to tweak for your specific situation: */
{
name: 'not-to-test',
comment:
"This module depends on code within a folder that should only contain tests. As tests don't " +
"implement functionality this is odd. Either you're writing a test outside the test folder " +
"or there's something in the test folder that isn't a test.",
severity: 'error',
from: {
pathNot: '^(tests)'
},
to: {
path: '^(tests)'
}
},
{
name: 'not-to-spec',
comment:
'This module depends on a spec (test) file. The sole responsibility of a spec file is to test code. ' +
"If there's something in a spec that's of use to other modules, it doesn't have that single " +
'responsibility anymore. Factor it out into (e.g.) a separate utility/ helper or a mock.',
severity: 'error',
from: {},
to: {
path: '\\.(spec|test)\\.(js|mjs|cjs|ts|ls|coffee|litcoffee|coffee\\.md)$'
}
},
{
name: 'not-to-dev-dep',
severity: 'error',
comment:
"This module depends on an npm package from the 'devDependencies' section of your " +
'package.json. It looks like something that ships to production, though. To prevent problems ' +
"with npm packages that aren't there on production declare it (only!) in the 'dependencies'" +
'section of your package.json. If this module is development only - add it to the ' +
'from.pathNot re of the not-to-dev-dep rule in the dependency-cruiser configuration',
from: {
path: '^(src)',
pathNot: '\\.(spec|test)\\.(js|mjs|cjs|ts|ls|coffee|litcoffee|coffee\\.md)$'
},
to: {
dependencyTypes: [
'npm-dev'
]
}
},
{
name: 'optional-deps-used',
severity: 'info',
comment:
"This module depends on an npm package that is declared as an optional dependency " +
"in your package.json. As this makes sense in limited situations only, it's flagged here. " +
"If you're using an optional dependency here by design - add an exception to your" +
"dependency-cruiser configuration.",
from: {},
to: {
dependencyTypes: [
'npm-optional'
]
}
},
{
name: 'peer-deps-used',
comment:
"This module depends on an npm package that is declared as a peer dependency " +
"in your package.json. This makes sense if your package is e.g. a plugin, but in " +
"other cases - maybe not so much. If the use of a peer dependency is intentional " +
"add an exception to your dependency-cruiser configuration.",
severity: 'warn',
from: {},
to: {
dependencyTypes: [
'npm-peer'
]
}
}
],
options: {
/* conditions specifying which files not to follow further when encountered:
- path: a regular expression to match
- dependencyTypes: see https://github.com/sverweij/dependency-cruiser/blob/master/doc/rules-reference.md#dependencytypes-and-dependencytypesnot
for a complete list
*/
doNotFollow: {
path: 'node_modules'
},
/* conditions specifying which dependencies to exclude
- path: a regular expression to match
- dynamic: a boolean indicating whether to ignore dynamic (true) or static (false) dependencies.
leave out if you want to exclude neither (recommended!)
*/
// exclude : {
// path: '',
// dynamic: true
// },
/* pattern specifying which files to include (regular expression)
dependency-cruiser will skip everything not matching this pattern
*/
// includeOnly : '',
/* dependency-cruiser will include modules matching against the focus
regular expression in its output, as well as their neighbours (direct
dependencies and dependents)
*/
// focus : '',
/* list of module systems to cruise */
// moduleSystems: ['amd', 'cjs', 'es6', 'tsd'],
/* prefix for links in html and svg output (e.g. 'https://github.com/you/yourrepo/blob/develop/'
to open it on your online repo or `vscode://file/${process.cwd()}/` to
open it in visual studio code),
*/
// prefix: '',
/* false (the default): ignore dependencies that only exist before typescript-to-javascript compilation
true: also detect dependencies that only exist before typescript-to-javascript compilation
"specify": for each dependency identify whether it only exists before compilation or also after
*/
tsPreCompilationDeps: true,
/*
list of extensions to scan that aren't javascript or compile-to-javascript.
Empty by default. Only put extensions in here that you want to take into
account that are _not_ parsable.
*/
// extraExtensionsToScan: [".json", ".jpg", ".png", ".svg", ".webp"],
/* if true combines the package.jsons found from the module up to the base
folder the cruise is initiated from. Useful for how (some) mono-repos
manage dependencies & dependency definitions.
*/
// combinedDependencies: false,
/* if true leave symlinks untouched, otherwise use the realpath */
// preserveSymlinks: false,
/* TypeScript project file ('tsconfig.json') to use for
(1) compilation and
(2) resolution (e.g. with the paths property)
The (optional) fileName attribute specifies which file to take (relative to
dependency-cruiser's current working directory). When not provided
defaults to './tsconfig.json'.
*/
tsConfig: {
fileName: 'tsconfig.json'
},
/* Webpack configuration to use to get resolve options from.
The (optional) fileName attribute specifies which file to take (relative
to dependency-cruiser's current working directory. When not provided defaults
to './webpack.conf.js'.
The (optional) `env` and `args` attributes contain the parameters to be passed if
your webpack config is a function and takes them (see webpack documentation
for details)
*/
// webpackConfig: {
// fileName: './webpack.config.js',
// env: {},
// args: {},
// },
/* Babel config ('.babelrc', '.babelrc.json', '.babelrc.json5', ...) to use
for compilation (and whatever other naughty things babel plugins do to
source code). This feature is well tested and usable, but might change
behavior a bit over time (e.g. more precise results for used module
systems) without dependency-cruiser getting a major version bump.
*/
// babelConfig: {
// fileName: './.babelrc'
// },
/* List of strings you have in use in addition to cjs/ es6 requires
& imports to declare module dependencies. Use this e.g. if you've
re-declared require, use a require-wrapper or use window.require as
a hack.
*/
// exoticRequireStrings: [],
/* options to pass on to enhanced-resolve, the package dependency-cruiser
uses to resolve module references to disk. You can set most of these
options in a webpack.conf.js - this section is here for those
projects that don't have a separate webpack config file.
Note: settings in webpack.conf.js override the ones specified here.
*/
enhancedResolveOptions: {
/* List of strings to consider as 'exports' fields in package.json. Use
['exports'] when you use packages that use such a field and your environment
supports it (e.g. node ^12.19 || >=14.7 or recent versions of webpack).
If you have an `exportsFields` attribute in your webpack config, that one
will have precedence over the one specified here.
*/
exportsFields: ["exports"],
/* List of conditions to check for in the exports field. e.g. use ['imports']
if you're only interested in exposed es6 modules, ['require'] for commonjs,
or all conditions at once `(['import', 'require', 'node', 'default']`)
if anything goes for you. Only works when the 'exportsFields' array is
non-empty.
If you have a 'conditionNames' attribute in your webpack config, that one will
have precedence over the one specified here.
*/
conditionNames: ["import", "require", "node", "default"],
/*
The extensions, by default are the same as the ones dependency-cruiser
can access (run `npx depcruise --info` to see which ones that are in
_your_ environment. If that list is larger than what you need (e.g.
it contains .js, .jsx, .ts, .tsx, .cts, .mts - but you don't use
TypeScript you can pass just the extensions you actually use (e.g.
[".js", ".jsx"]). This can speed up the most expensive step in
dependency cruising (module resolution) quite a bit.
*/
// extensions: [".js", ".jsx", ".ts", ".tsx", ".d.ts"],
/*
If your TypeScript project makes use of types specified in 'types'
fields in package.jsons of external dependencies, specify "types"
in addition to "main" in here, so enhanced-resolve (the resolver
dependency-cruiser uses) knows to also look there. You can also do
this if you're not sure, but still use TypeScript. In a future version
of dependency-cruiser this will likely become the default.
*/
mainFields: ["main", "types"],
},
reporterOptions: {
dot: {
/* pattern of modules that can be consolidated in the detailed
graphical dependency graph. The default pattern in this configuration
collapses everything in node_modules to one folder deep so you see
the external modules, but not the innards your app depends upon.
*/
collapsePattern: 'node_modules/(@[^/]+/[^/]+|[^/]+)',
/* Options to tweak the appearance of your graph.See
https://github.com/sverweij/dependency-cruiser/blob/master/doc/options-reference.md#reporteroptions
for details and some examples. If you don't specify a theme
don't worry - dependency-cruiser will fall back to the default one.
*/
// theme: {
// graph: {
// /* use splines: "ortho" for straight lines. Be aware though
// graphviz might take a long time calculating ortho(gonal)
// routings.
// */
// splines: "true"
// },
// modules: [
// {
// criteria: { matchesFocus: true },
// attributes: {
// fillcolor: "lime",
// penwidth: 2,
// },
// },
// {
// criteria: { matchesFocus: false },
// attributes: {
// fillcolor: "lightgrey",
// },
// },
// {
// criteria: { matchesReaches: true },
// attributes: {
// fillcolor: "lime",
// penwidth: 2,
// },
// },
// {
// criteria: { matchesReaches: false },
// attributes: {
// fillcolor: "lightgrey",
// },
// },
// {
// criteria: { source: "^src/model" },
// attributes: { fillcolor: "#ccccff" }
// },
// {
// criteria: { source: "^src/view" },
// attributes: { fillcolor: "#ccffcc" }
// },
// ],
// dependencies: [
// {
// criteria: { "rules[0].severity": "error" },
// attributes: { fontcolor: "red", color: "red" }
// },
// {
// criteria: { "rules[0].severity": "warn" },
// attributes: { fontcolor: "orange", color: "orange" }
// },
// {
// criteria: { "rules[0].severity": "info" },
// attributes: { fontcolor: "blue", color: "blue" }
// },
// {
// criteria: { resolved: "^src/model" },
// attributes: { color: "#0000ff77" }
// },
// {
// criteria: { resolved: "^src/view" },
// attributes: { color: "#00770077" }
// }
// ]
// }
},
archi: {
/* pattern of modules that can be consolidated in the high level
graphical dependency graph. If you use the high level graphical
dependency graph reporter (`archi`) you probably want to tweak
this collapsePattern to your situation.
*/
collapsePattern: '^(packages|src|lib|app|bin|test(s?)|spec(s?))/[^/]+|node_modules/(@[^/]+/[^/]+|[^/]+)',
/* Options to tweak the appearance of your graph.See
https://github.com/sverweij/dependency-cruiser/blob/master/doc/options-reference.md#reporteroptions
for details and some examples. If you don't specify a theme
for 'archi' dependency-cruiser will use the one specified in the
dot section (see above), if any, and otherwise use the default one.
*/
// theme: {
// },
},
"text": {
"highlightFocused": true
},
}
}
};
// generated: dependency-cruiser@12.11.0 on 2023-03-24T14:11:38.647Z

View File

@@ -3,6 +3,56 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.24.2](https://github.com/certd/certd/compare/v1.24.1...v1.24.2) (2024-09-06)
### Bug Fixes
* 修复复制流水线出现的各种问题 ([6314e8d](https://github.com/certd/certd/commit/6314e8d7eb58cd52e2a7bd3b5ffb9112b0b69577))
### Performance Improvements
* 阶段、任务、步骤全面支持拖动排序 ([bd73a16](https://github.com/certd/certd/commit/bd73a163cd0497f062bd424ddc6bc9bbc95f81ea))
* 任务配置不需要的字段可以自动隐藏 ([192d9dc](https://github.com/certd/certd/commit/192d9dc7e36737d684c769f255f407c28b1152ac))
* 任务支持拖动排序 ([1e9b563](https://github.com/certd/certd/commit/1e9b5638aa36a8ce70019a9c750230ba41938327))
* client 请求超时时间延长为10s ([ff46771](https://github.com/certd/certd/commit/ff46771d8dd43e71c1ca70e3ba783945750342cc))
## [1.24.1](https://github.com/certd/certd/compare/v1.24.0...v1.24.1) (2024-09-02)
### Bug Fixes
* 激活仅限管理员 ([1c17970](https://github.com/certd/certd/commit/1c17970b981f0987c506744ee6b2283fd5e40493))
### Performance Improvements
* 授权配置支持加密 ([42a56b5](https://github.com/certd/certd/commit/42a56b581d754c3e5f9838179d19ab0d004ef2eb))
* 支持阿里云 DCDN ([98b77f8](https://github.com/certd/certd/commit/98b77f80843834616fb26f83b4c42245326abd06))
* 支持已跳过的步骤重新运行 ([ea775ad](https://github.com/certd/certd/commit/ea775adae18d57a04470cfba6b9460d761d74035))
* 支持cdnfly ([724a850](https://github.com/certd/certd/commit/724a85028b4a7146c9e3b4df4497dcf2a7bf7c67))
* 支持ftp上传 ([b9bddbf](https://github.com/certd/certd/commit/b9bddbfabb5664365f1232e9432532187c98006c))
# [1.24.0](https://github.com/certd/certd/compare/v1.23.1...v1.24.0) (2024-08-25)
### Bug Fixes
* 部署到腾讯云cdn选择证书任务步骤限制只能选证书 ([3345c14](https://github.com/certd/certd/commit/3345c145b802170f75a098a35d0c4b8312efcd17))
* 修复执行日志没有清理的bug ([22a3363](https://github.com/certd/certd/commit/22a336370a88a7df2a23c967043bae153da71ed5))
### Features
* 支持ECC类型 ([a7424e0](https://github.com/certd/certd/commit/a7424e02f5c7e02ac1688791040785920ce67473))
### Performance Improvements
* 优化成功后跳过的提示 ([7b451bb](https://github.com/certd/certd/commit/7b451bbf6e6337507f4627b5a845f5bd96ab4f7b))
* 优化证书申请成功率 ([968c469](https://github.com/certd/certd/commit/968c4690a07f69c08dcb3d3a494da4e319627345))
* email proxy ([453f1ba](https://github.com/certd/certd/commit/453f1baa0b9eb0f648aa1b71ccf5a95b202ce13f))
## [1.23.1](https://github.com/certd/certd/compare/v1.23.0...v1.23.1) (2024-08-06)
### Performance Improvements
* 优化默认值设置 ([1af19f0](https://github.com/certd/certd/commit/1af19f0ac053fe109782882964533636b5969d6b))
# [1.23.0](https://github.com/certd/certd/compare/v1.22.9...v1.23.0) (2024-08-05)
### Features

View File

@@ -1,6 +1,6 @@
{
"name": "@certd/ui-client",
"version": "1.23.0",
"version": "1.24.2",
"private": true,
"scripts": {
"dev": "vite --open",
@@ -23,13 +23,14 @@
"license": "AGPL-3.0",
"dependencies": {
"@ant-design/colors": "^7.0.2",
"@ant-design/icons-vue": "^7.0.1",
"@ant-design/icons-vue": "^6.1.0",
"@fast-crud/fast-crud": "^1.21.2",
"@fast-crud/fast-extends": "^1.21.2",
"@fast-crud/ui-antdv4": "^1.21.2",
"@fast-crud/ui-interface": "^1.21.2",
"@iconify/vue": "^4.1.1",
"@soerenmartius/vue3-clipboard": "^0.1.2",
"@vue-js-cron/light": "^4.0.5",
"ant-design-vue": "^4.1.2",
"axios": "^1.7.2",
"axios-mock-adapter": "^1.22.0",
@@ -54,10 +55,10 @@
"vue-cropperjs": "^5.0.0",
"vue-i18n": "^9.10.2",
"vue-router": "^4.3.0",
"vuedraggable": "^2.24.3"
"vuedraggable": "^4.1.0"
},
"devDependencies": {
"@certd/pipeline": "^1.22.8",
"@certd/pipeline": "^1.24.2",
"@rollup/plugin-commonjs": "^25.0.7",
"@rollup/plugin-node-resolve": "^15.2.3",
"@types/chai": "^4.3.12",
@@ -104,7 +105,7 @@
"tslint": "^6.1.3",
"typescript": "5.4.2",
"unplugin-vue-define-options": "^1.4.2",
"vite": "^5.1.6",
"vite": "^5.3.1",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-html": "^3.2.2",
"vite-plugin-windicss": "^1.9.3",

View File

@@ -5,9 +5,20 @@ export type SysPublicSetting = {
managerOtherUserPipeline: boolean;
};
export type SysInstallInfo = {
siteId: string;
};
export async function getSysPublicSettings(): Promise<SysPublicSetting> {
return await request({
url: "/basic/settings/public",
method: "get"
});
}
export async function getInstallInfo(): Promise<SysInstallInfo> {
return await request({
url: "/basic/settings/install",
method: "get"
});
}

View File

@@ -60,7 +60,14 @@ export async function mine(): Promise<UserInfoRes> {
});
}
return await request({
url: "/sys/authority/user/mine",
url: "/mine/info",
method: "post"
});
}
export async function getPlusInfo() {
return await request({
url: "/mine/plusInfo",
method: "post"
});
}

View File

@@ -112,7 +112,7 @@ function createRequestFunction(service: any) {
headers: {
"Content-Type": get(config, "headers.Content-Type", "application/json")
},
timeout: 5000,
timeout: 10000,
baseURL: env.API,
data: {}
};

View File

@@ -0,0 +1,81 @@
<template>
<div class="cron-editor">
<div class="flex-o">
<cron-light
:disabled="disabled"
:readonly="readonly"
:period="period"
class="flex-o cron-ant"
locale="zh-CN"
format="quartz"
:model-value="modelValue"
@update:model-value="onUpdate"
@error="onError"
/>
</div>
<div class="mt-5">
<a-input :disabled="true" :readonly="readonly" :value="modelValue" @change="onChange"></a-input>
</div>
<div class="fs-helper">{{ errorMessage }}</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from "vue";
const props = defineProps<{
modelValue?: string;
disabled?: boolean;
readonly?: boolean;
}>();
const period = ref<string>("day");
const emit = defineEmits<{
"update:modelValue": any;
}>();
const errorMessage = ref<string | null>(null);
const onUpdate = (value: string) => {
if (value === props.modelValue) {
return;
}
emit("update:modelValue", value);
errorMessage.value = undefined;
};
const onPeriod = (value: string) => {
period.value = value;
};
const onChange = (e: any) => {
const value = e.target.value;
onUpdate(value);
};
const onError = (error: any) => {
errorMessage.value = error;
};
</script>
<style lang="less">
.cron-editor {
.cron-ant {
flex-wrap: wrap;
&* > {
margin-bottom: 2px;
display: flex;
align-items: center;
}
.vcron-select-list {
min-width: 56px;
}
.vcron-select-input {
min-height: 22px;
background-color: #fff;
}
.vcron-select-container {
display: flex;
align-items: center;
}
}
}
</style>

View File

@@ -1,18 +1,28 @@
import PiContainer from "./container.vue";
import PiAccessSelector from "../views/certd/access/access-selector/index.vue";
import PiDnsProviderSelector from "./dns-provider-selector/index.vue";
import PiOutputSelector from "../views/certd/pipeline/pipeline/component/output-selector/index.vue";import PiEditable from "./editable.vue";
import PiOutputSelector from "../views/certd/pipeline/pipeline/component/output-selector/index.vue";
import PiEditable from "./editable.vue";
import vip from "./vip-button/install.js";
import { CheckCircleOutlined, InfoCircleOutlined, UndoOutlined } from "@ant-design/icons-vue";
import CronEditor from "./cron-editor/index.vue";
import { CronLight } from "@vue-js-cron/light";
import "@vue-js-cron/light/dist/light.css";
export default {
install(app:any) {
install(app: any) {
app.component("PiContainer", PiContainer);
app.component("PiAccessSelector", PiAccessSelector);
app.component("PiEditable", PiEditable);
app.component("PiOutputSelector", PiOutputSelector);
app.component("PiDnsProviderSelector", PiDnsProviderSelector);
app.component("CronLight", CronLight);
app.component("CronEditor", CronEditor);
app.component("CheckCircleOutlined", CheckCircleOutlined);
app.component("InfoCircleOutlined", InfoCircleOutlined);
app.component("UndoOutlined", UndoOutlined);
app.use(vip);
}
};

View File

@@ -0,0 +1,9 @@
import { request } from "/src/api/service";
export async function doActive(form: any) {
return await request({
url: "/sys/plus/active",
method: "post",
data: form
});
}

View File

@@ -0,0 +1,23 @@
import { message, notification } from "ant-design-vue";
import { useUserStore } from "/@/store/modules/user";
export default {
mounted(el: any, binding: any, vnode: any) {
const { value } = binding;
const userStore = useUserStore();
el.className = el.className + " need-plus";
if (!userStore.isPlus) {
function checkPlus() {
// 事件处理代码
notification.warn({
message: "此为专业版功能,请升级到专业版"
});
}
el.addEventListener("click", function (event: any) {
checkPlus();
});
el.addEventListener("move", function (event: any) {
checkPlus();
});
}
}
};

View File

@@ -0,0 +1,180 @@
<template>
<div class="layout-vip isPlus" @click="openUpgrade">
<contextHolder />
<fs-icon icon="mingcute:vip-1-line" :title="text.title" />
<div v-if="mode !== 'icon'" class="text">
<a-tooltip>
<template #title> {{ text.title }}</template>
<span>{{ text.name }}</span>
</a-tooltip>
</div>
</div>
</template>
<script lang="tsx" setup>
import { ref, reactive, computed } from "vue";
import { useUserStore } from "/src/store/modules/user";
import dayjs from "dayjs";
import { message, Modal } from "ant-design-vue";
import * as api from "./api";
import { useSettingStore } from "/@/store/modules/settings";
const props = withDefaults(
defineProps<{
mode?: "button" | "nav" | "icon";
}>(),
{
mode: "button"
}
);
type Text = {
name: string;
title?: string;
};
const text = computed<Text>(() => {
const map = {
isPlus: {
button: {
name: "专业版已开通",
title: "到期时间:" + expireTime.value
},
icon: {
name: "",
title: "专业版已开通"
},
nav: {
name: "专业版",
title: "到期时间:" + expireTime.value
}
},
free: {
button: {
name: "此为专业版功能",
title: "升级专业版享受更多VIP特权"
},
icon: {
name: "",
title: "此为专业版功能"
},
nav: {
name: "免费版",
title: "升级专业版享受更多VIP特权"
}
}
};
if (userStore.isPlus) {
return map.isPlus[props.mode];
} else {
return map.free[props.mode];
}
});
const userStore = useUserStore();
const expireTime = computed(() => {
if (userStore.isPlus) {
return dayjs(userStore.plusInfo.expireTime).format("YYYY-MM-DD");
}
return "";
});
const expiredDays = computed(() => {
if (userStore.plusInfo?.isPlus && !userStore.isPlus) {
//已过期多少天
const days = dayjs().diff(dayjs(userStore.plusInfo.expireTime), "day");
return `专业版已过期${days}`;
}
return "";
});
const formState = reactive({
code: ""
});
async function doActive() {
if (!formState.code) {
message.error("请输入激活码");
throw new Error("请输入激活码");
}
const res = await api.doActive(formState);
if (res) {
await userStore.reInit();
Modal.success({
title: "激活成功",
content: `您已成功激活专业版,有效期至:${dayjs(userStore.plusInfo.expireTime).format("YYYY-MM-DD")}`
});
}
}
const settingStore = useSettingStore();
const computedSiteId = computed(() => settingStore.installInfo?.siteId);
const [modal, contextHolder] = Modal.useModal();
function openUpgrade() {
if (!userStore.isAdmin) {
message.info("仅限管理员操作");
return;
}
const placeholder = "请输入激活码";
const isPlus = userStore.isPlus;
modal.confirm({
title: isPlus ? "续期专业版" : "激活专业版",
async onOk() {
return await doActive();
},
okText: "激活",
width: 500,
content: () => {
return (
<div class="mt-10 mb-10">
<div>
<h3 class="block-header">专业版特权</h3>
<ul>
<li>可加VIP群需求优先实现</li>
<li>证书流水线数量无限制免费版限制10条</li>
<li>免配置发邮件功能</li>
<li>FTP上传cdnfly宝塔易盾等部署插件</li>
<li>更多特权敬请期待</li>
</ul>
</div>
<div>
<h3 class="block-header">{isPlus ? "续期" : "立刻激活"}</h3>
<div>{isPlus ? "当前专业版已激活,到期时间" + dayjs(userStore.plusInfo.expireTime).format("YYYY-MM-DD") : ""}</div>
<div class="mt-10">
<div class="flex-o w-100">
<span>站点ID</span>
<fs-copyable class="flex-1" v-model={computedSiteId.value}></fs-copyable>
</div>
<a-input class="mt-10" v-model:value={formState.code} placeholder={placeholder} />
</div>
<div class="mt-10">
没有激活码
<a href="https://afdian.com/a/greper" target="_blank">
爱发电赞助VIP会员后获取
</a>
</div>
</div>
</div>
);
}
});
}
</script>
<style lang="less">
.layout-vip {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
cursor: pointer;
&.isPlus {
color: #c5913f;
}
.text {
margin-left: 5px;
}
}
</style>

View File

@@ -0,0 +1,6 @@
import VipButton from "./index.vue";
import plus from "./directive.js";
export default function (app: any) {
app.component("VipButton", VipButton);
app.directive("plus", plus);
}

View File

@@ -12,14 +12,14 @@
<a-layout class="layout-body">
<a-layout-header class="header">
<div class="header-buttons">
<div class="header-left header-buttons">
<div class="menu-fold" @click="asideCollapsedToggle">
<MenuUnfoldOutlined v-if="asideCollapsed" />
<MenuFoldOutlined v-else />
</div>
<fs-menu class="header-menu" mode="horizontal" :expand-selected="false" :selectable="false" :menus="frameworkMenus" />
<vip-button class="flex-center header-btn" mode="nav" />
</div>
<fs-menu class="header-menu" mode="horizontal" :expand-selected="false" :selectable="false" :menus="frameworkMenus" />
<div class="header-right header-buttons">
<!-- <button-->
<!-- w:bg="blue-400 hover:blue-500 dark:blue-500 dark:hover:blue-600"-->
@@ -83,10 +83,11 @@ import { MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons-vue";
import FsThemeSet from "/@/layout/components/theme/index.vue";
import { env } from "../utils/util.env";
import FsThemeModeSet from "./components/theme/mode-set.vue";
import VipButton from "/@/components/vip-button/index.vue";
export default {
name: "LayoutFramework",
// eslint-disable-next-line vue/no-unused-components
components: { FsThemeSet, MenuFoldOutlined, MenuUnfoldOutlined, FsMenu, FsLocale, FsSourceLink, FsUserInfo, FsTabs, FsThemeModeSet },
components: { FsThemeSet, MenuFoldOutlined, MenuUnfoldOutlined, FsMenu, FsLocale, FsSourceLink, FsUserInfo, FsTabs, FsThemeModeSet, VipButton },
setup() {
const resourceStore = useResourceStore();
const frameworkMenus = computed(() => {
@@ -133,6 +134,7 @@ export default {
.fs-framework {
height: 100%;
overflow-x: hidden;
min-width: 1200px;
.menu-fold {
display: flex;
justify-content: center;
@@ -174,34 +176,41 @@ export default {
padding: 5px;
}
}
.header-buttons {
display: flex;
align-items: center;
& > * {
cursor: pointer;
padding: 0 10px;
}
height: 100%;
& > .header-btn {
display: inline-flex;
justify-content: center;
.ant-layout-header.header {
display: flex;
justify-content: space-between;
align-items: center;
.header-buttons {
display: flex;
align-items: center;
& > * {
cursor: pointer;
padding: 0 10px;
}
height: 100%;
//border-bottom: 1px solid rgba(255, 255, 255, 0);
&:hover {
background-color: #fff;
& > .header-btn {
display: inline-flex;
justify-content: center;
align-items: center;
height: 100%;
//border-bottom: 1px solid rgba(255, 255, 255, 0);
&:hover {
background-color: #fff;
}
}
}
.header-right {
justify-content: flex-end;
align-items: center;
display: flex;
}
.header-menu {
flex: 1;
}
}
.header-right {
justify-content: flex-end;
align-items: center;
display: flex;
}
.header-menu {
flex: 1;
}
.aside-menu {
flex: 1;
ui {

View File

@@ -1,7 +1,6 @@
<template>
<div id="userLayout" :class="['user-layout-wrapper']">
<div class="login-container flex-center">
<div class="user-layout-lang"></div>
<div class="user-layout-content">
<div class="top flex flex-col items-center justify-center">
<div class="header flex flex-row items-center">
@@ -146,7 +145,6 @@ export default {
// position: absolute;
width: 100%;
bottom: 0;
padding: 0 16px;
margin: 48px 0 24px;
text-align: center;

View File

@@ -33,6 +33,7 @@ router.beforeEach(async (to, from, next) => {
// 请根据自身业务需要修改
const token = userStore.getToken;
if (token) {
await userStore.init();
next();
} else {
// 没有登录的时候跳转到登录界面

View File

@@ -5,7 +5,7 @@ import _ from "lodash-es";
import { LocalStorage } from "/src/utils/util.storage";
import * as basicApi from "/@/api/modules/api.basic";
import { SysPublicSetting } from "/@/api/modules/api.basic";
import { SysInstallInfo, SysPublicSetting } from "/@/api/modules/api.basic";
export type ThemeToken = {
token: {
@@ -21,6 +21,9 @@ export interface SettingState {
themeConfig?: ThemeConfig;
themeToken: ThemeToken;
sysPublic?: SysPublicSetting;
installInfo?: {
siteId: string;
};
}
const defaultThemeConfig = {
@@ -39,6 +42,9 @@ export const useSettingStore = defineStore({
sysPublic: {
registerEnabled: false,
managerOtherUserPipeline: false
},
installInfo: {
siteId: ""
}
}),
getters: {
@@ -47,12 +53,18 @@ export const useSettingStore = defineStore({
},
getSysPublic(): SysPublicSetting {
return this.sysPublic;
},
getInstallInfo(): SysInstallInfo {
return this.installInfo;
}
},
actions: {
async loadSysSettings() {
const settings = await basicApi.getSysPublicSettings();
_.merge(this.sysPublic, settings);
const installInfo = await basicApi.getInstallInfo();
_.merge(this.installInfo, installInfo);
},
persistThemeConfig() {
LocalStorage.set(SETTING_THEME_KEY, this.getThemeConfig);

View File

@@ -1,21 +1,28 @@
import { defineStore } from "pinia";
import { store } from "../index";
import router from "../../router";
// @ts-ignore
import { LocalStorage } from "/src/utils/util.storage";
// @ts-ignore
import * as UserApi from "/src/api/modules/api.user";
import { RegisterReq } from "/src/api/modules/api.user";
// @ts-ignore
import { LoginReq, UserInfoRes } from "/@/api/modules/api.user";
import { Modal, notification } from "ant-design-vue";
import { message, Modal, notification } from "ant-design-vue";
import { useI18n } from "vue-i18n";
import { mitter } from "/src/utils/util.mitt";
import { RegisterReq } from "/src/api/modules/api.user";
interface UserState {
userInfo: Nullable<UserInfoRes>;
token?: string;
plusInfo?: PlusInfo;
inited: boolean;
}
interface PlusInfo {
vipType: string;
expireTime: number;
isPlus: boolean;
}
const USER_INFO_KEY = "USER_INFO";
@@ -26,7 +33,10 @@ export const useUserStore = defineStore({
// user info
userInfo: null,
// token
token: undefined
token: undefined,
// plus
plusInfo: null,
inited: false
}),
getters: {
getUserInfo(): UserInfoRes {
@@ -37,6 +47,9 @@ export const useUserStore = defineStore({
},
isAdmin(): boolean {
return this.getUserInfo?.id === 1;
},
isPlus(): boolean {
return this.plusInfo?.isPlus && this.plusInfo?.expireTime > new Date().getTime();
}
},
actions: {
@@ -54,7 +67,14 @@ export const useUserStore = defineStore({
LocalStorage.remove(TOKEN_KEY);
LocalStorage.remove(USER_INFO_KEY);
},
checkPlus() {
if (!this.isPlus) {
notification.warn({
message: "此为专业版功能,请先升级到专业版"
});
throw new Error("此为专业版功能,请升级到专业版");
}
},
async register(user: RegisterReq) {
await UserApi.register(user);
notification.success({
@@ -73,10 +93,7 @@ export const useUserStore = defineStore({
// save token
this.setToken(token, expire);
// get user info
const userInfo = await this.getUserInfoAction();
await router.replace("/");
mitter.emit("app.login", { userInfo, token: data });
return userInfo;
return await this.onLoginSuccess(data);
} catch (error) {
return null;
}
@@ -86,6 +103,19 @@ export const useUserStore = defineStore({
this.setUserInfo(userInfo);
return userInfo;
},
async onLoginSuccess(loginData: any) {
await this.getUserInfoAction();
await this.loadPlusInfo();
const userInfo = await this.getUserInfoAction();
mitter.emit("app.login", { userInfo, token: loginData, plusInfo: this.plusInfo });
await router.replace("/");
return userInfo;
},
async loadPlusInfo() {
this.plusInfo = await UserApi.getPlusInfo();
},
/**
* @description: logout
*/
@@ -108,6 +138,19 @@ export const useUserStore = defineStore({
await this.logout(true);
}
});
},
async init() {
if (this.inited) {
return;
}
if (this.getToken) {
await this.loadPlusInfo();
}
this.inited = true;
},
async reInit() {
this.inited = false;
await this.init();
}
}
});

View File

@@ -14,9 +14,6 @@ html, body {
box-sizing: border-box;
}
body{
min-width: 1000px;
}
div#app {
height: 100%
}
@@ -25,15 +22,14 @@ h1, h2, h3, h4, h5, h6 {
margin-bottom: 0;
}
.fs-desc{
.fs-desc {
font-size: 12px;
color:#888888;
color: #888888;
margin-left: 5px;
margin-right: 5px;
}
.ant-btn-link {
height: 24px;
}
@@ -48,55 +44,88 @@ h1, h2, h3, h4, h5, h6 {
vertical-align: 0 !important;
}
.flex-o{
.pointer {
cursor: pointer;
}
.flex-center {
display: flex;
justify-content: center;
align-items: center;
}
.flex-o {
display: flex !important;
align-items: center;
}
.flex{
.flex {
display: flex;
align-items: center;
}
.flex-1{
.flex-1 {
flex: 1;
}
.ml-5{
margin-left:5px;
}
.ml-20{
margin-left:20px;
}
.ml-15{
margin-left:15px;
.mb-2 {
margin-bottom: 2px;
}
.mr-5{
.ml-5 {
margin-left: 5px;
}
.ml-10 {
margin-left: 10px;
}
.ml-20 {
margin-left: 20px;
}
.ml-15 {
margin-left: 15px;
}
.mr-5 {
margin-right: 5px;
}
.mr-20{
margin-right: 20px;
}
.mr-15{
margin-right: 15px;
}
.mt-10{
margin-top:10px;
}
.mb-10{
margin-bottom: 10px;
}
.m-10{
margin:10px;
.mr-10 {
margin-right: 10px;
}
.p-5{
padding:5px;
.mr-20 {
margin-right: 20px;
}
.p-10{
padding:10px;
.mr-15 {
margin-right: 15px;
}
.mt-5 {
margin-top: 5px;
}
.mt-10 {
margin-top: 10px;
}
.mb-10 {
margin-bottom: 10px;
}
.m-10 {
margin: 10px;
}
.p-5 {
padding: 5px;
}
.p-10 {
padding: 10px;
}
.ellipsis {
@@ -105,6 +134,35 @@ h1, h2, h3, h4, h5, h6 {
text-overflow: ellipsis;
}
.w-100{
width: 100%;
.w-100 {
width: 100%;
}
.block-header {
margin: 3px;
padding-top: 15px;
padding-bottom: 3px;
border-bottom: 1px solid #dedede;
}
.color-blue {
color: #1890ff;
}
.icon-box {
display: inline-flex;
align-items: center;
justify-content: center;
.fs-icon {
display: flex;
align-items: center;
justify-content: center;
}
}
.need-plus {
color: #c5913f !important;
}

View File

@@ -1,17 +0,0 @@
import _ from "lodash-es";
import { compute } from "@fast-crud/fast-crud";
export function useReference(form: any) {
if (!form.reference) {
return;
}
for (const reference of form.reference) {
_.set(
form,
reference.dest,
compute<any>((scope) => {
return _.get(scope, reference.src);
})
);
}
}

View File

@@ -0,0 +1,40 @@
import _ from "lodash-es";
import { compute } from "@fast-crud/fast-crud";
export function useReference(formItem: any) {
if (formItem.reference) {
for (const reference of formItem.reference) {
_.set(
formItem,
reference.dest,
compute<any>((scope) => {
return _.get(scope, reference.src);
})
);
}
delete formItem.reference;
}
if (formItem.mergeScript) {
const ctx = {
compute
};
const script = formItem.mergeScript;
const func = new Function("ctx", script);
const merged = func(ctx);
_.merge(formItem, merged);
delete formItem.mergeScript;
}
//helper
if (formItem.helper && typeof formItem.helper === "string") {
//正则表达式替换 [name](url) 成 <a href="url" >
let helper = formItem.helper.replace(/\[(.*)\]\((.*)\)/g, '<a href="$2" target="_blank">$1</a>');
helper = helper.replace(/\n/g, "<br/>");
formItem.helper = {
render: () => {
return <div innerHTML={helper}></div>;
}
};
}
}

View File

@@ -1,4 +1,4 @@
import { ColumnCompositionProps, dict } from "@fast-crud/fast-crud";
import { ColumnCompositionProps, dict, compute } from "@fast-crud/fast-crud";
// @ts-ignore
import * as api from "./api";
// @ts-ignore
@@ -16,7 +16,7 @@ export function getCommonColumnDefine(crudExpose: any, typeRef: any) {
}
};
function buildDefineFields(define: any) {
function buildDefineFields(define: any, form: any) {
const formWrapperRef = crudExpose.getFormWrapperRef();
const columnsRef = toRef(formWrapperRef.formOptions, "columns");
@@ -32,7 +32,26 @@ export function getCommonColumnDefine(crudExpose: any, typeRef: any) {
...value,
key
};
columnsRef.value[key] = _.merge({ title: key }, defaultPluginConfig, field);
let column = _.merge({ title: key }, defaultPluginConfig, field);
//eval
if (column.mergeScript) {
const ctx = {
compute
};
const script = column.mergeScript;
delete column.mergeScript;
const func = new Function("ctx", script);
const merged = func(ctx);
column = _.merge(column, merged);
}
//设置默认值
if (column.value != null && _.get(form, key) == null) {
_.set(form, key, column.value);
}
//字段配置赋值
columnsRef.value[key] = column;
console.log("form", columnsRef.value);
});
}
@@ -50,18 +69,26 @@ export function getCommonColumnDefine(crudExpose: any, typeRef: any) {
},
form: {
component: {
disabled: false
disabled: false,
showSearch: true,
filterOption: (input: string, option: any) => {
input = input?.toLowerCase();
return option.value.toLowerCase().indexOf(input) >= 0 || option.label.toLowerCase().indexOf(input) >= 0;
}
},
rules: [{ required: true, message: "请选择类型" }],
valueChange: {
immediate: true,
async handle({ value, mode, form }) {
async handle({ value, mode, form, immediate }) {
if (value == null) {
return;
}
const define = await api.GetProviderDefine(value);
console.log("define", define);
buildDefineFields(define);
if (!immediate) {
form.access = {};
}
buildDefineFields(define, form);
}
}
},

View File

@@ -59,11 +59,11 @@ export function Save(pipelineEntity: any) {
});
}
export function Trigger(id: any) {
export function Trigger(id: any, stepId?: string) {
return request({
url: apiPrefix + "/trigger",
method: "post",
params: { id }
params: { id, stepId }
});
}

View File

@@ -22,12 +22,17 @@ export default function (certPluginGroup: PluginGroup, formWrapperRef: any): Cre
form: {
...inputDefine,
show: compute((ctx) => {
console.log(formWrapperRef);
const form = formWrapperRef.value.getFormData();
if (!form) {
return false;
}
return form?.certApplyPlugin === plugin.name;
let inputDefineShow = true;
if (inputDefine.show != null) {
const computeShow = inputDefine.show as any;
inputDefineShow = computeShow.computeFn({ form });
}
return form?.certApplyPlugin === plugin.name && inputDefineShow;
})
}
};
@@ -39,7 +44,8 @@ export default function (certPluginGroup: PluginGroup, formWrapperRef: any): Cre
form: {
wrapper: {
width: "1150px",
saveRemind: false
saveRemind: false,
title: "创建证书申请流水线"
}
},
columns: {
@@ -59,8 +65,8 @@ export default function (certPluginGroup: PluginGroup, formWrapperRef: any): Cre
render: () => {
return (
<ul>
<li>Lego-ACMELego实现DNS提供商</li>
<li>JS-ACMEDNS属于阿里云Cloudflare可以选择用它来申请</li>
<li>JS-ACMEDNS属于阿里云Cloudflare西</li>
<li>Lego-ACMELego实现DNS提供商LEGO的用户可以使用</li>
</ul>
);
}
@@ -73,9 +79,11 @@ export default function (certPluginGroup: PluginGroup, formWrapperRef: any): Cre
type: "text",
form: {
component: {
name: "cron-editor",
vModel: "modelValue",
placeholder: "0 0 4 * * *"
},
helper: "请输入cron表达式, 例如0 0 4 * * *每天凌晨4点触发",
helper: "点击上面的按钮,选择每天几点几分定时执行, 例如0 0 4 * * *每天凌晨4点0分0秒触发",
order: 100
}
},

View File

@@ -2,7 +2,7 @@ import * as api from "./api";
import { useI18n } from "vue-i18n";
import { computed, ref } from "vue";
import { useRouter } from "vue-router";
import { AddReq, CreateCrudOptionsProps, CreateCrudOptionsRet, DelReq, dict, EditReq, UserPageQuery, UserPageRes } from "@fast-crud/fast-crud";
import { AddReq, CreateCrudOptionsProps, CreateCrudOptionsRet, DelReq, dict, EditReq, UserPageQuery, UserPageRes, useUi } from "@fast-crud/fast-crud";
import { statusUtil } from "/@/views/certd/pipeline/pipeline/utils/util.status";
import { nanoid } from "nanoid";
import { message, Modal } from "ant-design-vue";
@@ -10,11 +10,52 @@ import { env } from "/@/utils/util.env";
import { useUserStore } from "/@/store/modules/user";
import dayjs from "dayjs";
import { useSettingStore } from "/@/store/modules/settings";
import _ from "lodash-es";
export default function ({ crudExpose, context: { certdFormRef } }: CreateCrudOptionsProps): CreateCrudOptionsRet {
const router = useRouter();
const { t } = useI18n();
const lastResRef = ref();
function setRunnableIds(pipeline: any) {
const idMap: any = {};
function createId(oldId: any) {
if (oldId == null) {
return nanoid();
}
const newId = nanoid();
idMap[oldId] = newId;
return newId;
}
if (pipeline.stages) {
for (const stage of pipeline.stages) {
stage.id = createId(stage.id);
if (stage.tasks) {
for (const task of stage.tasks) {
task.id = createId(task.id);
if (task.steps) {
for (const step of task.steps) {
step.id = createId(step.id);
}
}
}
}
}
}
for (const trigger of pipeline.triggers) {
trigger.id = nanoid();
}
for (const notification of pipeline.notifications) {
notification.id = nanoid();
}
let content = JSON.stringify(pipeline);
for (const key in idMap) {
content = content.replaceAll(key, idMap[key]);
}
return JSON.parse(content);
}
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
return await api.GetList(query);
};
@@ -29,9 +70,23 @@ export default function ({ crudExpose, context: { certdFormRef } }: CreateCrudOp
};
const addRequest = async ({ form }: AddReq) => {
form.content = JSON.stringify({
title: form.title
});
if (form.content == null) {
form.content = JSON.stringify({
title: form.title
});
} else {
//复制的流水线
delete form.status;
delete form.lastHistoryTime;
delete form.lastVars;
delete form.createTime;
delete form.id;
let pipeline = JSON.parse(form.content);
pipeline.title = form.title;
pipeline = setRunnableIds(pipeline);
form.content = JSON.stringify(pipeline);
}
const res = await api.AddObj(form);
lastResRef.value = res;
return res;
@@ -41,12 +96,11 @@ export default function ({ crudExpose, context: { certdFormRef } }: CreateCrudOp
// 添加certd pipeline
const triggers = [];
if (form.triggerCron) {
triggers.push({ id: nanoid(), title: "定时触发", type: "timer", props: { cron: form.triggerCron } });
triggers.push({ title: "定时触发", type: "timer", props: { cron: form.triggerCron } });
}
const notifications = [];
if (form.emailNotify) {
notifications.push({
id: nanoid(),
type: "email",
when: ["error", "turnToSuccess"],
options: {
@@ -54,19 +108,16 @@ export default function ({ crudExpose, context: { certdFormRef } }: CreateCrudOp
}
});
}
const pipeline = {
let pipeline = {
title: form.domains[0] + "证书自动化",
stages: [
{
id: nanoid(),
title: "证书申请阶段",
tasks: [
{
id: nanoid(),
title: "证书申请任务",
steps: [
{
id: nanoid(),
title: "申请证书",
input: {
renewDays: 20,
@@ -85,8 +136,10 @@ export default function ({ crudExpose, context: { certdFormRef } }: CreateCrudOp
triggers,
notifications
};
pipeline = setRunnableIds(pipeline);
const id = await api.Save({
title: pipeline.title,
content: JSON.stringify(pipeline),
keepHistoryCount: 30
});
@@ -112,7 +165,7 @@ export default function ({ crudExpose, context: { certdFormRef } }: CreateCrudOp
},
addCertd: {
order: 1,
text: "添加证书流水线",
text: "创建证书流水线",
type: "primary",
click() {
addCertdPipeline();
@@ -136,6 +189,21 @@ export default function ({ crudExpose, context: { certdFormRef } }: CreateCrudOp
router.push({ path: "/certd/pipeline/detail", query: { id: row.id, editMode: "false" } });
}
},
copy: {
click: async (context) => {
userStore.checkPlus();
const { ui } = useUi();
// @ts-ignore
let row = context[ui.tableColumn.row];
row = _.cloneDeep(row);
row.title = row.title + "_copy";
await crudExpose.openCopy({
row: row,
index: context.index
});
},
class: "need-plus"
},
config: {
order: 1,
title: null,
@@ -158,7 +226,7 @@ export default function ({ crudExpose, context: { certdFormRef } }: CreateCrudOp
const files = await api.GetFiles(row.id);
Modal.success({
title: "文件下载",
okText: "↑↑↑ 点击链接下载",
okText: "↑↑↑ 点击上面链接下载",
content: () => {
const children = [];
for (const file of files) {
@@ -342,7 +410,7 @@ export default function ({ crudExpose, context: { certdFormRef } }: CreateCrudOp
title: "历史记录保持数",
type: "number",
form: {
value: 30,
value: 20,
helper: "历史记录保持条数,多余的会被删除"
},
column: {

View File

@@ -54,9 +54,9 @@ export default defineComponent({
content: JSON.stringify(pipelineConfig)
});
},
async doTrigger(options: { pipelineId: number }) {
const { pipelineId } = options;
await api.Trigger(pipelineId);
async doTrigger(options: { pipelineId: number; stepId?: string }) {
const { pipelineId, stepId } = options;
await api.Trigger(pipelineId, stepId);
}
};

View File

@@ -5,7 +5,7 @@
</template>
<p>
<fs-date-format :model-value="runnable.status?.startTime"></fs-date-format>
<a-tag class="ml-1" :color="status.color" :closable="status.value === 'start'" @close="cancelTask">
<a-tag class="ml-5" :color="status.color" :closable="status.value === 'start'" @close="cancelTask">
{{ status.label }}
</a-tag>
<a-tag v-if="isCurrent" class="pointer" color="green" :closable="true" @close="cancel">当前</a-tag>

View File

@@ -12,6 +12,9 @@ export default {
modelValue: {
type: String,
default: undefined
},
from: {
type: String
}
},
emits: ["update:modelValue"],
@@ -23,7 +26,7 @@ export default {
const currentStepIndex = inject("currentStepIndex") as Ref<number>;
const currentTask = inject("currentTask") as Ref<any>;
const getPluginGroups = inject("getPluginGroups") as Ref<any>;
const getPluginGroups = inject("getPluginGroups") as any;
const pluginGroups = getPluginGroups();
function onCreate() {
options.value = pluginGroups.getPreStepOutputOptions({
@@ -32,6 +35,9 @@ export default {
currentStepIndex: currentStepIndex.value,
currentTask: currentTask.value
});
if (props.from) {
options.value = options.value.filter((item: any) => item.type === props.from);
}
if (props.modelValue == null && options.value.length > 0) {
ctx.emit("update:modelValue", options.value[0].value);
}
@@ -42,7 +48,7 @@ export default {
watch(
() => {
return pluginGroups.value.map;
return pluginGroups.value?.map;
},
() => {
onCreate();

View File

@@ -1,5 +1,5 @@
<template>
<span v-if="statusRef" class="pi-status-show">
<span v-if="statusRef" class="pi-status-show flex-o">
<template v-if="type === 'icon'">
<fs-icon class="status-icon" v-bind="statusRef" :style="{ color: statusRef.color }" />
</template>

View File

@@ -1,5 +1,5 @@
<template>
<a-drawer v-model:open="stepDrawerVisible" placement="right" :closable="true" width="700px" @after-open-change="stepDrawerOnAfterVisibleChange">
<a-drawer v-model:open="stepDrawerVisible" placement="right" :closable="true" width="700px">
<template #title>
编辑步骤
<a-button v-if="editMode" @click="stepDelete()">
@@ -25,6 +25,7 @@
<template #title>
<a-avatar :src="item.icon || '/images/plugin.png'" />
<span class="title">{{ item.title }}</span>
<vip-button v-if="item.needPlus" mode="icon" />
</template>
<template #description>
<span :title="item.desc">{{ item.desc }}</span>
@@ -58,27 +59,10 @@
:get-context-fn="blankFn"
/>
<template v-for="(item, key) in currentPlugin.input" :key="key">
<fs-form-item v-model="currentStep.input[key]" :item="item" :get-context-fn="blankFn" />
<fs-form-item v-if="item.show !== false" v-model="currentStep.input[key]" :item="item" :get-context-fn="blankFn" />
</template>
<fs-form-item
v-model="currentStep.strategy.runStrategy"
:item="{
title: '运行策略',
key: 'strategy.runStrategy',
component: {
name: 'a-select',
vModel: 'value',
options: [
{ value: 0, label: '正常运行(证书申请任务请选择它)' },
{ value: 1, label: '成功后跳过(非证书任务请选择它)' }
]
},
helper: '该任务运行成功一次之后下次运行是否跳过,保持默认即可',
rules: [{ required: true, message: '此项必填' }]
}"
:get-context-fn="blankFn"
/>
<fs-form-item v-model="currentStep.strategy.runStrategy" :item="runStrategyProps" :get-context-fn="blankFn" />
</a-form>
<template #footer>
@@ -98,9 +82,13 @@ import _ from "lodash-es";
import { nanoid } from "nanoid";
import { CopyOutlined } from "@ant-design/icons-vue";
import { PluginGroups } from "/@/views/certd/pipeline/pipeline/type";
import { useUserStore } from "/@/store/modules/user";
import { compute, useCompute } from "@fast-crud/fast-crud";
import { useReference } from "/@/use/use-refrence";
export default {
name: "PiStepForm",
// eslint-disable-next-line vue/no-unused-components
components: { CopyOutlined },
props: {
editMode: {
@@ -115,12 +103,12 @@ export default {
* @returns
*/
function useStepForm() {
const useStore = useUserStore();
const getPluginGroups: any = inject("getPluginGroups");
const pluginGroups: PluginGroups = getPluginGroups();
const mode: Ref = ref("add");
const callback: Ref = ref();
const currentStep: Ref = ref({ title: undefined, input: {} });
const currentPlugin: Ref = ref({});
const stepFormRef: Ref = ref(null);
const stepDrawerVisible: Ref = ref(false);
const rules: Ref = ref({
@@ -134,6 +122,10 @@ export default {
});
const stepTypeSelected = (item: any) => {
if (item.needPlus && !useStore.isPlus) {
message.warn("此插件需要开通专业版才能使用");
throw new Error("此插件需要开通专业版才能使用");
}
currentStep.value.type = item.name;
currentStep.value.title = item.title;
console.log("currentStepTypeChanged:", currentStep.value);
@@ -145,18 +137,12 @@ export default {
message.warn("请先选择类型");
return;
}
// 给step的input设置默认值
changeCurrentPlugin(currentStep.value);
//赋初始
//合并默认
_.merge(currentStep.value, { input: {}, strategy: { runStrategy: 0 } }, currentPlugin.value.default, currentStep.value);
for (const key in currentPlugin.value.input) {
const input = currentPlugin.value.input[key];
if (input.default != null) {
currentStep.value.input[key] = input.default ?? input.value;
}
}
};
const stepDrawerShow = () => {
@@ -166,15 +152,10 @@ export default {
stepDrawerVisible.value = false;
};
const stepDrawerOnAfterVisibleChange = (val: any) => {
console.log("stepDrawerOnAfterVisibleChange", val);
};
const stepOpen = (step: any, emit: any) => {
callback.value = emit;
currentStep.value = _.merge({ input: {}, strategy: {} }, step);
console.log("currentStepOpen", currentStep.value);
if (step.type) {
changeCurrentPlugin(currentStep.value);
}
@@ -205,33 +186,41 @@ export default {
stepOpen(step, emit);
};
const currentPluginDefine = ref();
function getContext() {
return {
form: currentStep.value.input
};
}
const { doComputed } = useCompute();
const currentPlugin = doComputed(() => {
return currentPluginDefine.value;
}, getContext);
const changeCurrentPlugin = (step: any) => {
const stepType = step.type;
const pluginDefine = pluginGroups.get(stepType);
if (pluginDefine) {
step.type = stepType;
step._isAdd = false;
currentPlugin.value = _.cloneDeep(pluginDefine);
for (let key in currentPlugin.value.input) {
const input = currentPlugin.value.input[key];
if (input?.reference) {
for (const reference of input.reference) {
_.set(
input,
reference.dest,
computed<any>(() => {
const scope = {
form: currentStep.value.input
};
return _.get(scope, reference.src);
})
);
}
}
//设置初始值
if (input.default != null && currentStep.value.input[key] == null) {
currentStep.value.input[key] = input.default ?? input.value;
}
step.type = stepType;
step._isAdd = false;
let pluginDefine = pluginGroups.get(stepType);
if (pluginDefine == null) {
console.log("插件未找到", stepType);
return;
}
pluginDefine = _.cloneDeep(pluginDefine);
const columns = pluginDefine.input;
for (let key in columns) {
const column = columns[key];
useReference(column);
}
currentPluginDefine.value = pluginDefine;
for (let key in pluginDefine.input) {
const column = pluginDefine.input[key];
//设置初始值
if ((column.default != null || column.value != null) && currentStep.value.input[key] == null) {
currentStep.value.input[key] = column.default ?? column.value;
}
}
@@ -285,7 +274,6 @@ export default {
stepView,
stepDrawerShow,
stepDrawerVisible,
stepDrawerOnAfterVisibleChange,
currentStep,
currentPlugin,
stepSave,
@@ -296,10 +284,36 @@ export default {
};
}
const runStrategyProps = ref({
title: "运行策略",
key: "strategy.runStrategy",
component: {
name: "a-select",
vModel: "value",
options: [
{ value: 0, label: "正常运行(证书申请任务请选择它)" },
{ value: 1, label: "成功后跳过(非证书任务请选择它)" }
]
},
helper: {
render: () => {
return (
<div>
<div>正常运行每次都运行证书任务需要每次都运行</div>
<div>成功后跳过在证书没变化时该任务成功一次之后跳过不重复部署</div>
<div>保持默认即可如果你想要再次测试部署可以临时设置为正常运行</div>
</div>
);
}
},
rules: [{ required: true, message: "此项必填" }]
});
return {
...useStepForm(),
labelCol: { span: 6 },
wrapperCol: { span: 16 }
wrapperCol: { span: 16 },
runStrategyProps
};
}
};
@@ -345,7 +359,7 @@ export default {
overflow-y: hidden;
.ant-card-meta-description {
font-size: 10px;
font-size: 12px;
line-height: 20px;
height: 40px;
color: #7f7f7f;

View File

@@ -43,25 +43,22 @@
<a-button type="primary" @click="stepAdd(currentTask)">添加步骤</a-button>
</template>
</a-descriptions>
<a-list class="step-list" item-layout="horizontal" :data-source="currentTask.steps">
<template #renderItem="{ item, index }">
<a-list-item>
<template #actions>
<a key="edit" @click="stepEdit(currentTask, item, index)">编辑</a>
<a key="edit" @click="stepCopy(currentTask, item, index)">复制</a>
<v-draggable v-model="currentTask.steps" class="step-list" handle=".handle" item-key="id" :disabled="!userStore.isPlus">
<template #item="{ element, index }">
<div class="step-row">
<div class="text">
<fs-icon icon="ion:flash"></fs-icon>
<h4 class="title">{{ element.title }}</h4>
</div>
<div class="action">
<a key="edit" @click="stepEdit(currentTask, element, index)">编辑</a>
<a key="edit" @click="stepCopy(currentTask, element, index)">复制</a>
<a key="remove" @click="stepDelete(currentTask, index)">删除</a>
</template>
<a-list-item-meta>
<template #title>
{{ item.title }}
</template>
<template #avatar>
<fs-icon icon="ion:flash"></fs-icon>
</template>
</a-list-item-meta>
</a-list-item>
<fs-icon v-plus class="icon-button handle" title="拖动排序" icon="ion:move-outline"></fs-icon>
</div>
</div>
</template>
</a-list>
</v-draggable>
</a-form-item>
</div>
</a-form>
@@ -85,10 +82,11 @@ import { nanoid } from "nanoid";
import PiStepForm from "../step-form/index.vue";
import { Modal } from "ant-design-vue";
import { CopyOutlined } from "@ant-design/icons-vue";
import VDraggable from "vuedraggable";
import { useUserStore } from "/@/store/modules/user";
export default {
name: "PiTaskForm",
components: { CopyOutlined, PiStepForm },
components: { CopyOutlined, PiStepForm, VDraggable },
props: {
editMode: {
type: Boolean,
@@ -97,6 +95,7 @@ export default {
},
emits: ["update"],
setup(props: any, ctx: any) {
const userStore = useUserStore();
function useStep() {
const stepFormRef: Ref<any> = ref(null);
const currentStepIndex = ref(0);
@@ -251,6 +250,7 @@ export default {
};
}
return {
userStore,
labelCol: { span: 6 },
wrapperCol: { span: 16 },
...useTaskForm(),
@@ -268,5 +268,42 @@ export default {
.ant-list .ant-list-item .ant-list-item-meta .ant-list-item-meta-title {
margin: 0;
}
.ant-list .ant-list-item .ant-list-item-action {
display: flex;
> li {
display: flex;
align-items: center;
}
}
.step-list {
padding: 10px;
.icon-button {
font-size: 18px;
color: #1677ff;
cursor: pointer;
}
.step-row {
padding: 10px;
display: flex;
align-items: center;
justify-content: space-between;
.text {
display: flex;
> * {
margin: 0px;
margin-right: 15px;
}
}
.action {
display: flex;
align-items: center;
> * {
margin-right: 15px;
font-size: 14px;
}
}
}
}
}
</style>

View File

@@ -15,13 +15,7 @@
</template>
<template v-if="currentTrigger">
<pi-container>
<a-form
ref="triggerFormRef"
class="trigger-form"
:model="currentTrigger"
:label-col="labelCol"
:wrapper-col="wrapperCol"
>
<a-form ref="triggerFormRef" class="trigger-form" :model="currentTrigger" :label-col="labelCol" :wrapper-col="wrapperCol">
<fs-form-item
v-model="currentTrigger.title"
:item="{
@@ -59,10 +53,10 @@
key: 'props.cron',
component: {
disabled: !editMode,
name: 'a-input',
vModel: 'value'
name: 'cron-editor',
vModel: 'modelValue'
},
helper: 'cron表达式例如: 0 0 3 * * * 表示每天凌晨3点触发',
helper: '点击上面的按钮,选择每天几点几分定时执行, 例如0 0 4 * * *每天凌晨4点0分0秒触发',
rules: [{ required: true, message: '此项必填' }]
}"
/>

View File

@@ -20,167 +20,204 @@
<div class="layout-left">
<div class="pipeline-container">
<div class="pipeline">
<div class="stages">
<div class="stage first-stage">
<div class="title">
<pi-editable model-value="触发源" :disabled="true" />
</div>
<div class="tasks">
<div class="task-container first-task">
<div class="line">
<div class="flow-line"></div>
</div>
<div class="task">
<a-button shape="round" type="primary" @click="run">
<fs-icon icon="ion:play"></fs-icon>
手动触发
</a-button>
</div>
<v-draggable v-model="pipeline.stages" class="stages" item-key="id" handle=".stage-move-handle" :disabled="!userStore.isPlus">
<template #header>
<div class="stage first-stage">
<div class="title stage-move-handle">
<pi-editable model-value="触发源" :disabled="true" />
</div>
<div v-for="(trigger, index) of pipeline.triggers" :key="trigger.id" class="task-container">
<div class="line">
<div class="flow-line"></div>
</div>
<div class="task">
<a-button shape="round" @click="triggerEdit(trigger, index)">
<fs-icon icon="ion:time"></fs-icon>
{{ trigger.title }}
</a-button>
</div>
</div>
<div v-if="editMode" class="task-container is-add">
<div class="line">
<div class="flow-line"></div>
</div>
<div class="task">
<a-button shape="round" type="dashed" @click="triggerAdd">
<fs-icon icon="ion:add-circle-outline"></fs-icon>
触发源定时
</a-button>
</div>
</div>
</div>
</div>
<div v-for="(stage, index) of pipeline.stages" :key="stage.id" class="stage" :class="{ 'last-stage': isLastStage(index) }">
<div class="title">
<pi-editable v-model="stage.title" :disabled="!editMode"></pi-editable>
</div>
<div class="tasks">
<div
v-for="(task, taskIndex) of stage.tasks"
:key="task.id"
class="task-container"
:class="{
'first-task': taskIndex === 0
}"
>
<div class="line">
<div class="flow-line"></div>
<fs-icon v-if="editMode" class="add-stage-btn" title="添加新阶段" icon="ion:add-circle" @click="stageAdd(index)"></fs-icon>
</div>
<div class="task">
<a-button shape="round" @click="taskEdit(stage, index, task, taskIndex)">
<span class="flex-o w-100">
<span class="ellipsis flex-1" :class="{ 'mr-15': editMode }">{{ task.title }}</span>
<pi-status-show :status="task.status?.result"></pi-status-show>
</span>
</a-button>
<fs-icon v-if="editMode" class="copy" title="复制" icon="ion:copy-outline" @click="taskCopy(stage, index, task)"></fs-icon>
</div>
</div>
<div v-if="editMode" class="task-container is-add">
<div class="line">
<div class="flow-line"></div>
</div>
<div class="task">
<a-tooltip>
<a-button type="dashed" shape="round" @click="taskAdd(stage, index)">
<fs-icon class="font-20" icon="ion:add-circle-outline"></fs-icon>
并行任务
<div class="tasks">
<div class="task-container first-task">
<div class="line line-right">
<div class="flow-line"></div>
</div>
<div class="task">
<a-button shape="round" type="primary" @click="run()">
<fs-icon icon="ion:play"></fs-icon>
手动触发
</a-button>
</a-tooltip>
</div>
</div>
<div v-for="(trigger, index) of pipeline.triggers" :key="trigger.id" class="task-container">
<div class="line line-right">
<div class="flow-line"></div>
</div>
<div class="task">
<a-button shape="round" @click="triggerEdit(trigger, index)">
<fs-icon icon="ion:time"></fs-icon>
{{ trigger.title }}
</a-button>
</div>
</div>
</div>
</div>
</div>
<div v-if="editMode" class="stage last-stage">
<div class="title">
<pi-editable model-value="新阶段" :disabled="true" />
</div>
<div class="tasks">
<div class="task-container first-task">
<div class="line">
<div class="flow-line"></div>
<fs-icon class="add-stage-btn" title="添加新阶段" icon="ion:add-circle" @click="stageAdd()"></fs-icon>
</div>
<div class="task">
<a-button shape="round" type="dashed" @click="stageAdd()">
<fs-icon icon="ion:add-circle-outline"></fs-icon>
添加任务
</a-button>
<div v-if="editMode" class="task-container is-add">
<div class="line line-right">
<div class="flow-line"></div>
</div>
<div class="task">
<a-button shape="round" type="dashed" @click="triggerAdd">
<fs-icon icon="ion:add-circle-outline"></fs-icon>
触发源定时
</a-button>
</div>
</div>
</div>
<div class="task-container">
<div class="line">
<div class="flow-line"></div>
</div>
<div class="task">
<a-button shape="round" type="dashed" @click="notificationAdd()">
<fs-icon icon="ion:add-circle-outline"></fs-icon>
</div>
</template>
添加通知
</a-button>
<template #item="{ element: stage, index }">
<div :key="stage.id" class="stage" :class="{ 'last-stage': isLastStage(index) }">
<div class="title">
<pi-editable v-model="stage.title" :disabled="!editMode"></pi-editable>
<div v-plus class="icon-box stage-move-handle">
<fs-icon v-if="editMode" title="拖动排序" icon="ion:move-outline"></fs-icon>
</div>
</div>
<div v-for="(item, ii) of pipeline.notifications" :key="ii" class="task-container">
<div class="line">
<div class="flow-line"></div>
</div>
<div class="task">
<a-button shape="round" @click="notificationEdit(item, ii as number)">
<fs-icon icon="ion:notifications"></fs-icon>
通知 {{ item.type }}
</a-button>
</div>
<v-draggable v-model="stage.tasks" item-key="id" class="tasks" group="task" handle=".task-move-handle" :disabled="!userStore.isPlus">
<template #item="{ element: task, index: taskIndex }">
<div
class="task-container"
:class="{
'first-task': taskIndex === 0
}"
>
<div class="line line-left">
<div class="flow-line"></div>
<fs-icon v-if="editMode" class="add-stage-btn" title="添加新阶段" icon="ion:add-circle" @click="stageAdd(index)"></fs-icon>
</div>
<div class="line line-right">
<div class="flow-line"></div>
</div>
<div class="task">
<a-button shape="round" @click="taskEdit(stage, index, task, taskIndex)">
<a-popover title="步骤" :trigger="editMode ? 'none' : 'hover'">
<!-- :open="true"-->
<template #content>
<div v-for="(item, index) of task.steps" class="flex-o w-100">
<span class="ellipsis flex-1">{{ index + 1 }}. {{ item.title }} </span>
<pi-status-show v-if="!editMode" :status="item.status?.result"></pi-status-show>
<fs-icon
v-if="!editMode"
class="pointer color-blue ml-2"
title="重新运行此步骤"
icon="SyncOutlined"
@click="run(item.id)"
></fs-icon>
</div>
</template>
<span class="flex-o w-100">
<span class="ellipsis flex-1 task-title" :class="{ 'in-edit': editMode }">{{ task.title }}</span>
<pi-status-show :status="task.status?.result"></pi-status-show>
</span>
</a-popover>
</a-button>
<div class="icon-box action copy">
<fs-icon v-if="editMode" title="复制" icon="ion:copy-outline" @click="taskCopy(stage, index, task)"></fs-icon>
</div>
<div v-plus class="icon-box task-move-handle action drag">
<fs-icon v-if="editMode" title="拖动排序" icon="ion:move-outline"></fs-icon>
</div>
</div>
</div>
</template>
<template #footer>
<div v-if="editMode" class="task-container is-add">
<div class="line line-left">
<div class="flow-line"></div>
</div>
<div class="line line-right">
<div class="flow-line"></div>
</div>
<div class="task">
<a-tooltip>
<a-button type="dashed" shape="round" @click="taskAdd(stage, index)">
<fs-icon class="font-20" icon="ion:add-circle-outline"></fs-icon>
并行任务
</a-button>
</a-tooltip>
</div>
</div>
</template>
</v-draggable>
</div>
</template>
<template #footer>
<div v-if="editMode" class="stage last-stage">
<div class="title">
<pi-editable model-value="新阶段" :disabled="true" />
</div>
</div>
</div>
<div v-else class="stage last-stage">
<div class="title">
<pi-editable model-value="结束" :disabled="true" />
</div>
<div v-if="pipeline.notifications?.length > 0" class="tasks">
<div v-for="(item, index) of pipeline.notifications" :key="index" class="task-container" :class="{ 'first-task': index == 0 }">
<div class="line">
<div class="flow-line"></div>
<div class="tasks">
<div class="task-container first-task">
<div class="line line-left">
<div class="flow-line"></div>
<fs-icon class="add-stage-btn" title="添加新阶段" icon="ion:add-circle" @click="stageAdd()"></fs-icon>
</div>
<div class="task">
<a-button shape="round" type="dashed" @click="stageAdd()">
<fs-icon icon="ion:add-circle-outline"></fs-icon>
添加任务
</a-button>
</div>
</div>
<div class="task">
<a-button shape="round" @click="notificationEdit(item, index)">
<fs-icon icon="ion:notifications"></fs-icon>
<div class="task-container">
<div class="line line-left">
<div class="flow-line"></div>
</div>
<div class="task">
<a-button shape="round" type="dashed" @click="notificationAdd()">
<fs-icon icon="ion:add-circle-outline"></fs-icon>
通知 {{ item.type }}
</a-button>
添加通知
</a-button>
</div>
</div>
<div v-for="(item, ii) of pipeline.notifications" :key="ii" class="task-container">
<div class="line line-left">
<div class="flow-line"></div>
</div>
<div class="task">
<a-button shape="round" @click="notificationEdit(item, ii as number)">
<fs-icon icon="ion:notifications"></fs-icon>
通知 {{ item.type }}
</a-button>
</div>
</div>
</div>
</div>
<div v-else class="tasks">
<div class="task-container first-task">
<div class="line">
<div class="flow-line"></div>
<div v-else class="stage last-stage">
<div class="title">
<pi-editable model-value="结束" :disabled="true" />
</div>
<div v-if="pipeline.notifications?.length > 0" class="tasks">
<div v-for="(item, index) of pipeline.notifications" :key="index" class="task-container" :class="{ 'first-task': index == 0 }">
<div class="line line-left">
<div class="flow-line"></div>
</div>
<div class="task">
<a-button shape="round" @click="notificationEdit(item, index)">
<fs-icon icon="ion:notifications"></fs-icon>
通知 {{ item.type }}
</a-button>
</div>
</div>
<div class="task">
<a-button shape="round" type="dashed">
<fs-icon icon="ion:notifications"></fs-icon>
通知未设置
</a-button>
</div>
<div v-else class="tasks">
<div class="task-container first-task">
<div class="line line-left">
<div class="flow-line"></div>
</div>
<div class="task">
<a-button shape="round" type="dashed">
<fs-icon icon="ion:notifications"></fs-icon>
通知未设置
</a-button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
</v-draggable>
</div>
</div>
</div>
@@ -219,17 +256,19 @@ import PiTriggerForm from "./component/trigger-form/index.vue";
import PiNotificationForm from "./component/notification-form/index.vue";
import PiTaskView from "./component/task-view/index.vue";
import PiStatusShow from "./component/status-show.vue";
import VDraggable from "vuedraggable";
import _ from "lodash-es";
import { message, Modal, notification } from "ant-design-vue";
import { pluginManager } from "/@/views/certd/pipeline/pipeline/plugin";
import { nanoid } from "nanoid";
import { PipelineDetail, PipelineOptions, PluginGroups, RunHistory } from "./type";
import type { Runnable } from "@certd/pipeline";
import PiHistoryTimelineItem from "/@/views/certd/pipeline/pipeline/component/history-timeline-item.vue";
import { FsIcon } from "@fast-crud/fast-crud";
import { useUserStore } from "/@/store/modules/user";
export default defineComponent({
name: "PipelineEdit",
// eslint-disable-next-line vue/no-unused-components
components: { PiHistoryTimelineItem, PiTaskForm, PiTriggerForm, PiTaskView, PiStatusShow, PiNotificationForm },
components: { FsIcon, PiHistoryTimelineItem, PiTaskForm, PiTriggerForm, PiTaskView, PiStatusShow, PiNotificationForm, VDraggable },
props: {
pipelineId: {
type: [Number, String],
@@ -260,6 +299,8 @@ export default defineComponent({
router.back();
}
const userStore = useUserStore();
const loadCurrentHistoryDetail = async () => {
console.log("load history logs");
const detail: RunHistory = await props.options?.getHistoryDetail({ historyId: currentHistory.value.id });
@@ -529,7 +570,7 @@ export default defineComponent({
function useActions() {
const saveLoading = ref();
const run = async () => {
const run = async (stepId?: string) => {
if (props.editMode) {
message.warn("请先保存,再运行管道");
return;
@@ -549,11 +590,12 @@ export default defineComponent({
//@ts-ignore
await changeCurrentHistory(null);
watchNewHistoryList();
await props.options.doTrigger({ pipelineId: pipeline.value.id });
await props.options.doTrigger({ pipelineId: pipeline.value.id, stepId: stepId });
notification.success({ message: "管道已经开始运行" });
}
});
};
function toggleEditMode(editMode: boolean) {
ctx.emit("update:editMode", editMode);
}
@@ -614,6 +656,7 @@ export default defineComponent({
currentHistory,
histories,
goBack,
userStore,
...useTaskRet,
...useStageRet,
...useTrigger(),
@@ -688,39 +731,39 @@ export default defineComponent({
.title {
padding: 20px;
color: gray;
}
&.first-stage {
.line {
width: 50% !important;
.flow-line {
border-left: 0;
}
display: flex;
.stage-move-handle {
cursor: move;
margin-left: 4px;
}
}
&.last-stage {
.line {
width: 50% !important;
left: 0;
right: auto;
.flow-line {
border-right: 0;
}
.add-stage-btn {
visibility: hidden;
}
}
}
//.sortable-ghost {
// .line {
// visibility: hidden;
// }
//}
.line {
height: 50px;
position: absolute;
top: -25px;
right: 0;
width: 100%;
width: 25px;
&.line-left {
left: 25px;
.flow-line {
border-right: 0;
}
}
&.line-right {
right: 25px;
.flow-line {
border-left: 0;
}
}
.flow-line {
height: 100%;
margin-left: 28px;
margin-right: 28px;
border: 1px solid #c7c7c7;
border-top: 0;
}
@@ -739,6 +782,52 @@ export default defineComponent({
}
}
.task-container:first-child {
.line {
width: 50px;
&.line-left {
left: 0;
.flow-line {
border-right: 0;
border-left: 0;
}
}
&.line-right {
right: 0;
.flow-line {
border-left: 0;
border-right: 0;
}
}
.add-stage-btn {
visibility: visible;
}
}
}
&.first-stage {
.line {
.flow-line {
border-left: 0;
}
}
}
&.last-stage {
.line {
width: 50% !important;
right: auto;
.flow-line {
border-right: 0;
}
.add-stage-btn {
visibility: hidden;
}
}
}
.tasks {
.task-container {
width: 100%;
@@ -748,18 +837,6 @@ export default defineComponent({
justify-content: center;
align-items: center;
position: relative;
&.first-task {
.line {
.flow-line {
margin: 0;
border-left: 0;
border-right: 0;
}
.add-stage-btn {
visibility: visible;
}
}
}
.task {
display: flex;
flex-direction: column;
@@ -768,14 +845,29 @@ export default defineComponent({
height: 100%;
z-index: 2;
.copy {
.task-title {
&.in-edit {
margin-right: 28px;
}
}
.action {
position: absolute;
right: 60px;
top: 18px;
//font-size: 18px;
cursor: pointer;
z-index: 10;
&:hover {
color: #1890ff;
}
&.copy {
right: 80px;
}
&.drag {
right: 60px;
cursor: move;
}
}
.ant-btn {

View File

@@ -1,5 +1,5 @@
import type { Pipeline } from "@certd/pipeline";
import { FormItemProps } from "@fast-crud/fast-crud";
import { DynamicType, FormItemProps } from "@fast-crud/fast-crud";
export type PipelineDetail = {
pipeline: Pipeline;
};
@@ -24,7 +24,7 @@ export type PluginDefine = {
title: string;
desc?: string;
input: {
[key: string]: FormItemProps;
[key: string]: DynamicType<FormItemProps>;
};
output: {
[key: string]: any;
@@ -117,7 +117,7 @@ export class PluginGroups {
}
export type PipelineOptions = {
doTrigger(options: { pipelineId: number }): Promise<void>;
doTrigger(options: { pipelineId: number; stepId?: string }): Promise<void>;
doSave(pipelineConfig: Pipeline): Promise<void>;
getPipelineDetail(query: { pipelineId: number }): Promise<PipelineDetail>;
getHistoryList(query: { pipelineId: number }): Promise<RunHistory[]>;

View File

@@ -1,49 +1,77 @@
<template>
<fs-page class="page-setting-email">
<template #header>
<div class="title">邮件设置</div>
<div class="title">
邮件设置
<span class="sub">设置邮件发送服务器</span>
</div>
</template>
<div class="email-form">
<a-form :model="formState" name="basic" :label-col="{ span: 8 }" :wrapper-col="{ span: 16 }" autocomplete="off" @finish="onFinish" @finish-failed="onFinishFailed">
<a-form-item label="SMTP域名" name="host" :rules="[{ required: true, message: '请输入smtp域名或ip' }]">
<a-input v-model:value="formState.host" />
</a-form-item>
<a-form-item label="SMTP端口" name="port" :rules="[{ required: true, message: '请输入smtp端口号' }]">
<a-input v-model:value="formState.port" />
</a-form-item>
<a-form-item label="用户名" :name="['auth', 'user']" :rules="[{ required: true, message: '请输入用户名' }]">
<a-input v-model:value="formState.auth.user" />
</a-form-item>
<a-form-item label="密码" :name="['auth', 'pass']" :rules="[{ required: true, message: '请输入密码' }]">
<a-input-password v-model:value="formState.auth.pass" />
<div class="helper">如果是qq邮箱需要到qq邮箱的设置里面申请授权码作为密码</div>
</a-form-item>
<a-form-item label="发件邮箱" name="sender" :rules="[{ required: true, message: '请输入发件邮箱' }]">
<a-input v-model:value="formState.sender" />
</a-form-item>
<a-form-item label="是否ssl" name="secure">
<a-switch v-model:checked="formState.secure" />
<div class="helper">ssl和非ssl的smtp端口是不一样的注意修改端口</div>
</a-form-item>
<a-form-item label="忽略证书校验" name="tls.rejectUnauthorized">
<a-switch v-model:checked="formState.tls.rejectUnauthorized" />
</a-form-item>
<a-form-item :wrapper-col="{ offset: 8, span: 16 }">
<a-button type="primary" html-type="submit">保存</a-button>
</a-form-item>
</a-form>
<div>
<a-form :model="testFormState" name="basic" :label-col="{ span: 8 }" :wrapper-col="{ span: 16 }" autocomplete="off" @finish="onTestSend">
<a-form-item label="测试收件邮箱" name="receiver" :rules="[{ required: true, message: '请输入测试收件邮箱' }]">
<a-input v-model:value="testFormState.receiver" />
<div class="flex-o">
<div v-if="!formState.usePlus" class="email-form">
<a-form
:model="formState"
name="basic"
:label-col="{ span: 8 }"
:wrapper-col="{ span: 16 }"
autocomplete="off"
@finish="onFinish"
@finish-failed="onFinishFailed"
>
<a-form-item label="使用自定义邮件服务器"> </a-form-item>
<a-form-item label="SMTP域名" name="host" :rules="[{ required: true, message: '请输入smtp域名或ip' }]">
<a-input v-model:value="formState.host" />
</a-form-item>
<a-form-item label="SMTP端口" name="port" :rules="[{ required: true, message: '请输入smtp端口号' }]">
<a-input v-model:value="formState.port" />
</a-form-item>
<a-form-item label="用户名" :name="['auth', 'user']" :rules="[{ required: true, message: '请输入用户名' }]">
<a-input v-model:value="formState.auth.user" />
</a-form-item>
<a-form-item label="密码" :name="['auth', 'pass']" :rules="[{ required: true, message: '请输入密码' }]">
<a-input-password v-model:value="formState.auth.pass" />
<div class="helper">如果是qq邮箱需要到qq邮箱的设置里面申请授权码作为密码</div>
</a-form-item>
<a-form-item label="发件邮箱" name="sender" :rules="[{ required: true, message: '请输入发件邮箱' }]">
<a-input v-model:value="formState.sender" />
</a-form-item>
<a-form-item label="是否ssl" name="secure">
<a-switch v-model:checked="formState.secure" />
<div class="helper">ssl和非ssl的smtp端口是不一样的注意修改端口</div>
</a-form-item>
<a-form-item label="忽略证书校验" name="tls.rejectUnauthorized">
<a-switch v-model:checked="formState.tls.rejectUnauthorized" />
</a-form-item>
<a-form-item :wrapper-col="{ offset: 8, span: 16 }">
<a-button type="primary" html-type="submit">测试</a-button>
<a-button type="primary" html-type="submit">保存</a-button>
</a-form-item>
</a-form>
</div>
<div class="email-form">
<a-form :label-col="{ span: 8 }" :wrapper-col="{ span: 16 }">
<a-form-item label="使用官方邮件服务器">
<div class="flex-o">
<a-switch v-model:checked="formState.usePlus" :disabled="!userStore.isPlus" @change="onUsePlusChanged" />
<vip-button class="ml-5" mode="button"></vip-button>
</div>
<div class="helper">使用官方邮箱服务器直接发邮件免除繁琐的配置</div>
</a-form-item>
</a-form>
</div>
</div>
<div class="email-form">
<a-form :model="testFormState" name="basic" :label-col="{ span: 8 }" :wrapper-col="{ span: 16 }" autocomplete="off" @finish="onTestSend">
<a-form-item label="测试收件邮箱" name="receiver" :rules="[{ required: true, message: '请输入测试收件邮箱' }]">
<a-input v-model:value="testFormState.receiver" />
<div class="helper">发送失败可以试试使用官方邮件服务器</div>
</a-form-item>
<a-form-item :wrapper-col="{ offset: 8, span: 16 }">
<a-button type="primary" :loading="testFormState.loading" html-type="submit">测试</a-button>
</a-form-item>
</a-form>
</div>
</fs-page>
</template>
@@ -55,6 +83,7 @@ import * as emailApi from "./api.email";
import { SettingKeys } from "./api";
import { notification } from "ant-design-vue";
import { useUserStore } from "/@/store/modules/user";
interface FormState {
host: string;
@@ -69,6 +98,7 @@ interface FormState {
rejectUnauthorized?: boolean;
};
sender: string;
usePlus: boolean;
}
const formState = reactive<Partial<FormState>>({
@@ -76,7 +106,8 @@ const formState = reactive<Partial<FormState>>({
user: "",
pass: ""
},
tls: {}
tls: {},
usePlus: false
});
async function load() {
@@ -99,6 +130,10 @@ const onFinishFailed = (errorInfo: any) => {
// console.log("Failed:", errorInfo);
};
async function onUsePlusChanged() {
await api.SettingsSave(SettingKeys.Email, formState);
}
interface TestFormState {
receiver: string;
loading: boolean;
@@ -118,6 +153,8 @@ async function onTestSend() {
testFormState.loading = false;
}
}
const userStore = useUserStore();
</script>
<style lang="less">
@@ -127,9 +164,9 @@ async function onTestSend() {
margin: 20px;
}
.helper{
padding:1px;
margin:0px;
.helper {
padding: 1px;
margin: 0px;
color: #999;
font-size: 10px;
}

View File

@@ -14,6 +14,6 @@ run/
.tsbuildinfo
.tsbuildinfo.*
/data/db.sqlite
/tools/windows/
*/node_modules
certd-server/tools/windows/
.clinic

View File

@@ -1,3 +1,10 @@
koa:
port: 7001
# key: ./data/ssl/cert.key
# cert: ./data/ssl/cert.crt
#plus:
# server:
# baseUrl: 'http://127.0.0.1:11007'
plus:
server:
baseUrl: 'https://api.ai.handsfree.work'

View File

@@ -1,6 +1,3 @@
koa:
port: 7001
flyway:
scriptDir: './db/migration-pg'
@@ -14,3 +11,10 @@ typeorm:
password: root
database: postgres
#plus:
# server:
# baseUrl: 'https://api.ai.handsfree.work'
plus:
server:
baseUrl: 'http://127.0.0.1:11007'

View File

@@ -1,2 +1,2 @@
koa:
port: 7001
preview:
enabled: true

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