Compare commits

...

57 Commits

Author SHA1 Message Date
yoan
c1cefe0e7f v0.2.11 2024-10-30 11:07:59 +08:00
yoan
55b77fdf5c Fix the issue where the deployment type could not be selected 2024-10-30 11:03:41 +08:00
yoan
16967c4ab1 fix tencent cdn deploy 2024-10-30 09:31:51 +08:00
yoan
61a4fd8657 v0.2.10 2024-10-30 07:04:05 +08:00
yoan
edeac86f06 Merge branch 'fudiwei-feat/multiple-certificate-formats' 2024-10-29 08:46:06 +08:00
yoan
4e0c23165f fix conflict 2024-10-29 08:45:51 +08:00
usual2970
feb851a3fc Merge pull request #273 from LeoChen98/enhance-tencent-cdn-dupe-deploy
enhance: resolve error on tencent cdn dupe deployment
2024-10-29 08:39:57 +08:00
usual2970
3103d60508 Merge pull request #274 from PittyXu/feat/k8s
fix: k8s部署更新报错
2024-10-29 08:39:15 +08:00
usual2970
53be6b5f5b Merge pull request #272 from LeoChen98/feat-add-mail-push
feat: add mail push
2024-10-29 08:38:10 +08:00
usual2970
9d3e0d1090 Merge pull request #278 from usual2970/feat/searchable_select
feat: Searchable when selecting authorization type
2024-10-29 08:37:53 +08:00
yoan
f8aef129cf Searchable when selecting authorization type 2024-10-28 22:52:25 +08:00
Leo Chen
c419b2c8b4 use slice pkg 2024-10-28 20:28:13 +08:00
Fu Diwei
b47a1a13cb feat: support jks format 2024-10-28 11:49:44 +08:00
徐雪君
3397f424bc fix: k8s部署更新报错 #266 2024-10-28 11:15:08 +08:00
yoan
48672d1a44 v0.2.9 2024-10-28 08:48:30 +08:00
Leo Chen
38dc8a63d9 enhance: resolve error on tencent cdn dupe deployment
优化:腾讯云cdn重复部署报错的问题
2024-10-27 23:48:52 +08:00
Fu Diwei
009e8fb976 feat: preset scripts on deployment to local 2024-10-27 21:10:19 +08:00
Fu Diwei
6d7a91f49b refactor: clean code 2024-10-27 20:44:38 +08:00
yoan
9d4d14db06 Update README.md 2024-10-27 20:42:47 +08:00
Leo Chen
c9f347f77a fix mail push onchange 2024-10-27 20:27:46 +08:00
Leo Chen
0396d8222e feat: add mail push
新增电子邮箱推送
2024-10-27 20:21:34 +08:00
Fu Diwei
305f3de50f Merge branch 'main' into feat/multiple-certificate-formats 2024-10-27 20:17:04 +08:00
yoan
ffacfe0f42 Merge branch 'LeoChen98-feat-serverchan-push-tube' 2024-10-27 09:18:46 +08:00
yoan
be9e66c7d3 Merge branch 'feat-serverchan-push-tube' of github.com:LeoChen98/certimate into LeoChen98-feat-serverchan-push-tube 2024-10-27 09:15:12 +08:00
yoan
1238508bdb Merge branch 'fudiwei-feat/cloud-load-balance' 2024-10-27 09:12:05 +08:00
yoan
1ab5c4035a fix conflict 2024-10-27 09:10:12 +08:00
yoan
67fa9d91bf Merge branch 'PittyXu-feat/k8s' 2024-10-27 08:38:44 +08:00
yoan
dc5f9abf20 detail ajustments 2024-10-27 08:37:42 +08:00
yoan
7240a42fbc Merge branch 'feat/k8s' of github.com:PittyXu/certimate into PittyXu-feat/k8s 2024-10-27 08:35:36 +08:00
yoan
6fbb6d4992 Merge branch 'LeoChen98-feat-tecent-ecdn-teo-deploy' 2024-10-27 08:33:00 +08:00
yoan
86838f305b detail ajustments 2024-10-27 08:32:48 +08:00
yoan
1b1b5939c5 Merge branch 'feat-tecent-ecdn-teo-deploy' of github.com:LeoChen98/certimate into LeoChen98-feat-tecent-ecdn-teo-deploy 2024-10-27 08:07:48 +08:00
Leo Chen
ffdd61b5ee feat: add ServerChan notifier
新增Server酱通知
2024-10-27 04:01:42 +08:00
Fu Diwei
adad5d86ba feat: support specified format on deployment to local/ssh 2024-10-27 00:19:34 +08:00
Fu Diwei
e7870e2b05 feat: support specified shell on deployment to local 2024-10-26 22:22:28 +08:00
徐雪君
548cbbfdd4 feat: k8s部署支持ServiceAccount权限 2024-10-26 22:15:16 +08:00
Fu Diwei
da4715e6dc fix: fix aliyun nlb endpoint 2024-10-26 13:18:15 +08:00
Fu Diwei
506ab4f18e feat: support quic listener in deployment to aliyun alb 2024-10-26 13:15:01 +08:00
Fu Diwei
d87026d5be feat: add aliyun nlb deployer 2024-10-26 12:52:55 +08:00
Fu Diwei
1690963aaf feat: add aliyun alb deployer 2024-10-26 12:40:45 +08:00
Fu Diwei
20d2c5699c feat: add aliyun clb deployer 2024-10-26 00:31:38 +08:00
Fu Diwei
e660e9cad1 feat: add aliyun slb uploader 2024-10-25 23:13:33 +08:00
Fu Diwei
26d7b0ba03 refactor: clean code 2024-10-25 23:03:52 +08:00
Leo Chen
ee097b3135 update README for tencent TEO support 2024-10-25 22:21:30 +08:00
Leo Chen
f5052e9a58 fix the missing parentheses 2024-10-25 22:18:40 +08:00
Leo Chen
3b3376899c add feat: tencent TEO deploy support
新增腾讯TEO(Edge One)部署方式
2024-10-25 22:16:27 +08:00
Leo Chen
a24a3595fa feat: add tencent ECDN deploy 2024-10-25 18:47:41 +08:00
Leo Chen
6a14d801f1 fix type incompatible error 2024-10-25 18:32:45 +08:00
yoan
332c5c5127 fix error type 2024-10-25 18:32:32 +08:00
usual2970
f9568f1a4a Merge pull request #254 from fudiwei/feat/cloud-load-balance
feat: huaweicloud elb deployer
2024-10-25 17:43:11 +08:00
usual2970
b458720dca Merge pull request #257 from belier-cn/main
feat: keep qiniu cdn https configuration
2024-10-25 16:16:20 +08:00
belier
935a320100 feat: keep qiniu cdn https configuration 2024-10-25 14:45:48 +08:00
Fu Diwei
024b3c936e Merge branch 'main' into feat/cloud-load-balance 2024-10-24 22:45:25 +08:00
Fu Diwei
dc720a5d99 feat: add huaweicloud elb deployer 2024-10-24 22:37:55 +08:00
Fu Diwei
af3e20709d refactor: clean code 2024-10-24 21:42:39 +08:00
Fu Diwei
ee531dd186 fix: aliyun oss deploy config validation error 2024-10-24 20:49:51 +08:00
Fu Diwei
cea6be37dc feat: allow set a different region on deployment to huaweicloud cdn 2024-10-24 20:16:23 +08:00
74 changed files with 5931 additions and 624 deletions

View File

@@ -71,22 +71,22 @@ make local.run
## 三、支持的服务商列表
| 服务商 | 支持申请证书 | 支持部署证书 | 备注 |
| :--------: | :----------: | :----------: | ------------------------------------------------------------ |
| 阿里云 | √ | √ | 可签发在阿里云注册的域名;可部署到阿里云 OSS、CDN |
| 腾讯云 | √ | √ | 可签发在腾讯云注册的域名;可部署到腾讯云 CDN、COS、CLB |
| 华为云 | √ | √ | 可签发在华为云注册的域名;可部署到华为云 CDN |
| 七牛云 | | √ | 可部署到七牛云 CDN |
| AWS | √ | | 可签发在 AWS Route53 托管的域名 |
| CloudFlare | √ | | 可签发在 CloudFlare 注册的域名CloudFlare 服务自带 SSL 证书 |
| GoDaddy | √ | | 可签发在 GoDaddy 注册的域名 |
| Namesilo | √ | | 可签发在 Namesilo 注册的域名 |
| PowerDNS | √ | | 可签发在 PowerDNS 托管的域名 |
| HTTP 请求 | √ | | 可签发允许通过 HTTP 请求修改 DNS 的域名 |
| 本地部署 | | √ | 可部署到本地服务器 |
| SSH | | √ | 可部署到 SSH 服务器 |
| Webhook | | √ | 可部署时回调到 Webhook |
| Kubernetes | | √ | 可部署到 Kubernetes Secret |
| 服务商 | 支持申请证书 | 支持部署证书 | 备注 |
| :--------: | :----------: | :----------: | ----------------------------------------------------------------- |
| 阿里云 | √ | √ | 可签发在阿里云注册的域名;可部署到阿里云 OSS、CDN、SLB |
| 腾讯云 | √ | √ | 可签发在腾讯云注册的域名;可部署到腾讯云 COS、CDN、ECDN、CLB、TEO |
| 华为云 | √ | √ | 可签发在华为云注册的域名;可部署到华为云 CDN、ELB |
| 七牛云 | | √ | 可部署到七牛云 CDN |
| AWS | √ | | 可签发在 AWS Route53 托管的域名 |
| CloudFlare | √ | | 可签发在 CloudFlare 注册的域名CloudFlare 服务自带 SSL 证书 |
| GoDaddy | √ | | 可签发在 GoDaddy 注册的域名 |
| Namesilo | √ | | 可签发在 Namesilo 注册的域名 |
| PowerDNS | √ | | 可签发在 PowerDNS 托管的域名 |
| HTTP 请求 | √ | | 可签发允许通过 HTTP 请求修改 DNS 的域名 |
| 本地部署 | | √ | 可部署到本地服务器 |
| SSH | | √ | 可部署到 SSH 服务器 |
| Webhook | | √ | 可部署时回调到 Webhook |
| Kubernetes | | √ | 可部署到 Kubernetes Secret |
## 四、系统截图
@@ -170,13 +170,21 @@ Certimate 是一个免费且开源的项目,采用 [MIT 开源协议](LICENSE.
支持更多服务商、UI 的优化改进、Bug 修复、文档完善等,欢迎大家提交 PR。
## 八、加入社区
## 八、免责声明
本软件依据 MIT 许可证MIT License发布免费提供旨在“按现状”供用户使用。作者及贡献者不对使用本软件所产生的任何直接或间接后果承担责任包括但不限于性能下降、数据丢失、服务中断、或任何其他类型的损害。
无任何保证:本软件不提供任何明示或暗示的保证,包括但不限于对特定用途的适用性、无侵权性、商用性及可靠性的保证。
用户责任:使用本软件即表示您理解并同意承担由此产生的一切风险及责任。
## 九、加入社区
- [Telegram-a new era of messaging](https://t.me/+ZXphsppxUg41YmVl)
- 微信群聊(超 200 人需邀请入群,可先加作者好友)
<img src="https://i.imgur.com/8xwsLTA.png" width="400"/>
## 、Star 趋势图
## 、Star 趋势图
[![Stargazers over time](https://starchart.cc/usual2970/certimate.svg?variant=adaptive)](https://starchart.cc/usual2970/certimate)

View File

@@ -59,7 +59,7 @@ make local.run
## Usage
After completing the installation steps above, you can access the Certimate management page by visiting http://127.0.0.1:8090 in your browser.
After completing the installation steps above, you can access the Certimate management page by visiting <http://127.0.0.1:8090> in your browser.
```bash
usernameadmin@certimate.fun
@@ -70,22 +70,22 @@ password1234567890
## List of Supported Providers
| Provider | Registration | Deployment | Remarks |
| :-----------: | :----------: | :--------: | ------------------------------------------------------------------------------------------- |
| Alibaba Cloud | √ | √ | Supports domains registered on Alibaba Cloud; supports deployment to Alibaba Cloud OSS, CDN |
| Tencent Cloud | √ | √ | Supports domains registered on Tencent Cloud; supports deployment to Tencent Cloud CDN, COS, CLB |
| Huawei Cloud | √ | √ | Supports domains registered on Huawei Cloud; supports deployment to Huawei Cloud CDN |
| Qiniu Cloud | | √ | Supports deployment to Qiniu Cloud CDN |
| AWS | √ | | Supports domains managed on AWS Route53 |
| CloudFlare | √ | | Supports domains registered on CloudFlare; CloudFlare services come with SSL certificates |
| GoDaddy | √ | | Supports domains registered on GoDaddy |
| Namesilo | √ | | Supports domains registered on Namesilo |
| PowerDNS | √ | | Supports domains managed on PowerDNS |
| HTTP Request | √ | | Supports domains which allow managing DNS by HTTP request |
| Local Deploy | | √ | Supports deployment to local servers |
| SSH | | √ | Supports deployment to SSH servers |
| Webhook | | √ | Supports callback to Webhook |
| Kubernetes | | √ | Supports deployment to Kubernetes Secret |
| Provider | Registration | Deployment | Remarks |
| :-----------: | :----------: | :--------: | --------------------------------------------------------------------------------------------------------------------- |
| Alibaba Cloud | √ | √ | Supports domains registered on Alibaba Cloud; supports deployment to Alibaba Cloud OSS, CDN,SLB |
| Tencent Cloud | √ | √ | Supports domains registered on Tencent Cloud; supports deployment to Tencent Cloud COS, CDN, ECDN, CLB, TEO |
| Huawei Cloud | √ | √ | Supports domains registered on Huawei Cloud; supports deployment to Huawei Cloud CDN, ELB |
| Qiniu Cloud | | √ | Supports deployment to Qiniu Cloud CDN |
| AWS | √ | | Supports domains managed on AWS Route53 |
| CloudFlare | √ | | Supports domains registered on CloudFlare; CloudFlare services come with SSL certificates |
| GoDaddy | √ | | Supports domains registered on GoDaddy |
| Namesilo | √ | | Supports domains registered on Namesilo |
| PowerDNS | √ | | Supports domains managed on PowerDNS |
| HTTP Request | √ | | Supports domains which allow managing DNS by HTTP request |
| Local Deploy | | √ | Supports deployment to local servers |
| SSH | | √ | Supports deployment to SSH servers |
| Webhook | | √ | Supports callback to Webhook |
| Kubernetes | | √ | Supports deployment to Kubernetes Secret |
## Screenshots
@@ -169,6 +169,14 @@ You can support the development of Certimate in the following ways:
Support for more service providers, UI enhancements, bug fixes, and documentation improvements are all welcome. We encourage everyone to submit pull requests (PRs).
## Disclaimer
This software is provided under the MIT License and distributed “as-is” without any warranty of any kind. The authors and contributors are not responsible for any damages or losses resulting from the use or inability to use this software, including but not limited to data loss, business interruption, or any other potential harm.
No Warranties: This software comes without any express or implied warranties, including but not limited to implied warranties of merchantability, fitness for a particular purpose, and non-infringement.
User Responsibility: By using this software, you agree to take full responsibility for any outcomes resulting from its use.
## Join the Community
- [Telegram-a new era of messaging](https://t.me/+ZXphsppxUg41YmVl)

15
go.mod
View File

@@ -5,9 +5,12 @@ go 1.22.0
toolchain go1.23.2
require (
github.com/alibabacloud-go/alb-20200616/v2 v2.2.1
github.com/alibabacloud-go/cas-20200407/v3 v3.0.1
github.com/alibabacloud-go/cdn-20180510/v5 v5.0.0
github.com/alibabacloud-go/darabonba-openapi/v2 v2.0.10
github.com/alibabacloud-go/nlb-20220430/v2 v2.0.3
github.com/alibabacloud-go/slb-20140515/v4 v4.0.9
github.com/alibabacloud-go/tea v1.2.2
github.com/alibabacloud-go/tea-utils/v2 v2.0.6
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible
@@ -16,16 +19,21 @@ require (
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.114
github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61
github.com/nikoksr/notify v1.0.0
github.com/pavlo-v-chernykh/keystore-go/v4 v4.5.0
github.com/pkg/sftp v1.13.6
github.com/pocketbase/dbx v1.10.1
github.com/pocketbase/pocketbase v0.22.18
github.com/qiniu/go-sdk/v7 v7.22.0
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cdn v1.0.1017
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1017
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1030
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ssl v1.0.992
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/teo v1.0.1030
golang.org/x/crypto v0.28.0
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56
k8s.io/api v0.31.1
k8s.io/apimachinery v0.31.1
k8s.io/client-go v0.31.1
software.sslmate.com/src/go-pkcs12 v0.5.0
)
require (
@@ -58,7 +66,6 @@ require (
go.mongodb.org/mongo-driver v1.12.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
k8s.io/api v0.31.1 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect
k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // indirect
@@ -107,7 +114,7 @@ require (
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.17.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.6 // indirect
github.com/ganigeorgiev/fexpr v0.4.1 // indirect
github.com/go-jose/go-jose/v4 v4.0.4 // indirect
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
@@ -151,7 +158,7 @@ require (
golang.org/x/mod v0.21.0 // indirect
golang.org/x/net v0.30.0 // indirect
golang.org/x/oauth2 v0.23.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/sync v0.8.0
golang.org/x/sys v0.26.0 // indirect
golang.org/x/term v0.25.0 // indirect
golang.org/x/text v0.19.0 // indirect

22
go.sum
View File

@@ -29,6 +29,8 @@ github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDe
github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c=
github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw=
github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82/go.mod h1:nLnM0KdK1CmygvjpDUO6m1TjSsiQtL61juhNsvV/JVI=
github.com/alibabacloud-go/alb-20200616/v2 v2.2.1 h1:b8ixnrkFhWrmJQd+iEE1UWPD5vdyC3d9l7G0uvkfi2s=
github.com/alibabacloud-go/alb-20200616/v2 v2.2.1/go.mod h1:cPdZwovbqpv+5nM/HnMwZpG5q0/gBuX31hu2H1VoyrM=
github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6 h1:eIf+iGJxdU4U9ypaUfbtOWCsZSbTb8AUHvyPrxu6mAA=
github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6/go.mod h1:4EUIoxs/do24zMOGGqYVWgw0s9NtiylnJglOeEB5UJo=
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc=
@@ -45,6 +47,8 @@ github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F
github.com/alibabacloud-go/darabonba-map v0.0.2 h1:qvPnGB4+dJbJIxOOfawxzF3hzMnIpjmafa0qOTp6udc=
github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.0.0/go.mod h1:5JHVmnHvGzR2wNdgaW1zDLQG8kOC4Uec8ubkMogW7OQ=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.0.5/go.mod h1:kUe8JqFmoVU7lfBauaDD5taFaW7mBI+xVsyHutYtabg=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.0.7/go.mod h1:CzQnh+94WDnJOnKZH5YRyouL+OOcdBnXY5VWAf0McgI=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.0.8/go.mod h1:CzQnh+94WDnJOnKZH5YRyouL+OOcdBnXY5VWAf0McgI=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.0.9/go.mod h1:bb+Io8Sn2RuM3/Rpme6ll86jMyFSrD1bxeV/+v61KeU=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.0.10 h1:GEYkMApgpKEVDn6z12DcH1EGYpDYRB8JxsazM4Rywak=
@@ -61,11 +65,15 @@ github.com/alibabacloud-go/debug v1.0.1 h1:MsW9SmUtbb1Fnt3ieC6NNZi6aEwrXfDksD4QA
github.com/alibabacloud-go/debug v1.0.1/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc=
github.com/alibabacloud-go/endpoint-util v1.1.0 h1:r/4D3VSw888XGaeNpP994zDUaxdgTSHBbVfZlzf6b5Q=
github.com/alibabacloud-go/endpoint-util v1.1.0/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE=
github.com/alibabacloud-go/nlb-20220430/v2 v2.0.3 h1:LtyUVlgBEKyzWgQJurzXM6MXCt84sQr9cE5OKqYymko=
github.com/alibabacloud-go/nlb-20220430/v2 v2.0.3/go.mod h1:4a/RcBYeAhYowHzX+LMgnouz7NradnSKPKl14KS3B1U=
github.com/alibabacloud-go/openapi-util v0.0.11/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws=
github.com/alibabacloud-go/openapi-util v0.1.0 h1:0z75cIULkDrdEhkLWgi9tnLe+KhAFE/r5Pb3312/eAY=
github.com/alibabacloud-go/openapi-util v0.1.0/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws=
github.com/alibabacloud-go/openplatform-20191219/v2 v2.0.1 h1:L0TIjr9Qh/SLVc1yPhFkcB9+9SbCNK/jPq4ZKB5zmnc=
github.com/alibabacloud-go/openplatform-20191219/v2 v2.0.1/go.mod h1:EKxBRDLcMzwl4VLF/1WJwlByZZECJawPXUvinKMsTTs=
github.com/alibabacloud-go/slb-20140515/v4 v4.0.9 h1:nrf9gQth7fONUj7V8i78Yb98eb9NdKl0VdeSjmeYugI=
github.com/alibabacloud-go/slb-20140515/v4 v4.0.9/go.mod h1:PEMEsQoxhkMvykMFP5ZXg6SWI9vmAiZ6lK3Pu4mTKB0=
github.com/alibabacloud-go/tea v1.1.0/go.mod h1:IkGyUSX4Ba1V+k4pCtJUc6jDpZLFph9QMy2VUPTwukg=
github.com/alibabacloud-go/tea v1.1.7/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
github.com/alibabacloud-go/tea v1.1.8/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
@@ -89,6 +97,7 @@ github.com/alibabacloud-go/tea-utils v1.3.6/go.mod h1:EI/o33aBfj3hETm4RLiAxF/ThQ
github.com/alibabacloud-go/tea-utils v1.4.5 h1:h0/6Xd2f3bPE4XHTvkpjwxowIwRCJAJOqY6Eq8f3zfA=
github.com/alibabacloud-go/tea-utils v1.4.5/go.mod h1:KNcT0oXlZZxOXINnZBs6YvgOd5aYp9U67G+E3R8fcQw=
github.com/alibabacloud-go/tea-utils/v2 v2.0.0/go.mod h1:U5MTY10WwlquGPS34DOeomUGBB0gXbLueiq5Trwu0C4=
github.com/alibabacloud-go/tea-utils/v2 v2.0.4/go.mod h1:sj1PbjPodAVTqGTA3olprfeeqqmwD0A5OQz94o9EuXQ=
github.com/alibabacloud-go/tea-utils/v2 v2.0.5/go.mod h1:dL6vbUT35E4F4bFTHL845eUloqaerYBYPsdWR2/jhe4=
github.com/alibabacloud-go/tea-utils/v2 v2.0.6 h1:ZkmUlhlQbaDC+Eba/GARMPy6hKdCLiSke5RsN5LcyQ0=
github.com/alibabacloud-go/tea-utils/v2 v2.0.6/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I=
@@ -197,6 +206,8 @@ github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I=
github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s=
github.com/gabriel-vasile/mimetype v1.4.6 h1:3+PzJTKLkvgjeTbts6msPJt4DixhT4YtFNf1gtGe3zc=
github.com/gabriel-vasile/mimetype v1.4.6/go.mod h1:JX1qVKqZd40hUPpAfiNTe0Sne7hdfKSbOqqmkq8GCXc=
github.com/gammazero/toposort v0.1.1/go.mod h1:H2cozTnNpMw0hg2VHAYsAxmkHXBYroNangj2NTBQDvw=
github.com/ganigeorgiev/fexpr v0.4.1 h1:hpUgbUEEWIZhSDBtf4M9aUNfQQ0BZkGRaMePy7Gcx5k=
github.com/ganigeorgiev/fexpr v0.4.1/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
@@ -389,6 +400,8 @@ github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw=
github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A=
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU=
github.com/pavlo-v-chernykh/keystore-go/v4 v4.5.0 h1:2nosf3P75OZv2/ZO/9Px5ZgZ5gbKrzA3joN1QMfOGMQ=
github.com/pavlo-v-chernykh/keystore-go/v4 v4.5.0/go.mod h1:lAVhWwbNaveeJmxrxuSTxMgKpF6DjnuVpn6T8WiBwYQ=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -449,12 +462,15 @@ github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cdn v1.0.1017 h1:Oymmfm
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cdn v1.0.1017/go.mod h1:gnLxGXlLmF+jDqWR1/RVoF/UUwxQxomQhkc0oN7KeuI=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.992/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1002/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1017 h1:SXrldOXwgomYuATVAuz5ofpTjB+99qVELgdy5R5kMgI=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1017/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1030 h1:kwiUoCkooUgy7iPyhEEbio7WT21kGJUeZ5JeJfb/dYk=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1030/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.1002 h1:QwE0dRkAAbdf+eACnkNULgDn9ZKUJpPWRyXdqJolP5E=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.1002/go.mod h1:WdC0FYbqYhJwQ3kbqri6hVP5HAEp+rzX9FToItTAzUg=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ssl v1.0.992 h1:A6O89OlCJQUpNxGqC/E5By04UNKBryIt5olQIGOx8mg=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ssl v1.0.992/go.mod h1:BcvC7ZPdSlhRggVq4J1ToJlgv8bmODIAuSo0naFZOLo=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/teo v1.0.1030 h1:tlHbfQlAfL12J/5XF4indKl0cAA3vEn6TDiGZVsr050=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/teo v1.0.1030/go.mod h1:8dW6JByZKNDAPnjlXxBk9yDc+QGbldpa0tBRfi1kG+U=
github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w=
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
@@ -518,6 +534,8 @@ golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL
golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
@@ -763,3 +781,5 @@ sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+s
sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08=
sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=
software.sslmate.com/src/go-pkcs12 v0.5.0 h1:EC6R394xgENTpZ4RltKydeDUjtlM5drOYIG9c6TVj2M=
software.sslmate.com/src/go-pkcs12 v0.5.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=

View File

@@ -98,7 +98,7 @@ func newApplyUser(ca, email string) (*ApplyUser, error) {
if err != nil {
return nil, err
}
keyStr, err := x509.PrivateKeyToPEM(privateKey)
keyStr, err := x509.ConvertECPrivateKeyToPEM(privateKey)
if err != nil {
return nil, err
}
@@ -122,7 +122,7 @@ func (u ApplyUser) GetRegistration() *registration.Resource {
}
func (u *ApplyUser) GetPrivateKey() crypto.PrivateKey {
rs, _ := x509.ParsePrivateKeyFromPEM(u.key)
rs, _ := x509.ParseECPrivateKeyFromPEM(u.key)
return rs
}

View File

@@ -0,0 +1,265 @@
package deployer
import (
"context"
"encoding/json"
"errors"
"fmt"
alb20200616 "github.com/alibabacloud-go/alb-20200616/v2/client"
openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client"
"github.com/alibabacloud-go/tea/tea"
"github.com/usual2970/certimate/internal/domain"
"github.com/usual2970/certimate/internal/pkg/core/uploader"
)
type AliyunALBDeployer struct {
option *DeployerOption
infos []string
sdkClient *alb20200616.Client
sslUploader uploader.Uploader
}
func NewAliyunALBDeployer(option *DeployerOption) (Deployer, error) {
access := &domain.AliyunAccess{}
json.Unmarshal([]byte(option.Access), access)
client, err := (&AliyunALBDeployer{}).createSdkClient(
access.AccessKeyId,
access.AccessKeySecret,
option.DeployConfig.GetConfigAsString("region"),
)
if err != nil {
return nil, err
}
uploader, err := uploader.NewAliyunCASUploader(&uploader.AliyunCASUploaderConfig{
AccessKeyId: access.AccessKeyId,
AccessKeySecret: access.AccessKeySecret,
Region: option.DeployConfig.GetConfigAsString("region"),
})
if err != nil {
return nil, err
}
return &AliyunALBDeployer{
option: option,
infos: make([]string, 0),
sdkClient: client,
sslUploader: uploader,
}, nil
}
func (d *AliyunALBDeployer) GetID() string {
return fmt.Sprintf("%s-%s", d.option.AccessRecord.GetString("name"), d.option.AccessRecord.Id)
}
func (d *AliyunALBDeployer) GetInfo() []string {
return d.infos
}
func (d *AliyunALBDeployer) Deploy(ctx context.Context) error {
switch d.option.DeployConfig.GetConfigAsString("resourceType") {
case "loadbalancer":
if err := d.deployToLoadbalancer(ctx); err != nil {
return err
}
case "listener":
if err := d.deployToListener(ctx); err != nil {
return err
}
default:
return errors.New("unsupported resource type")
}
return nil
}
func (d *AliyunALBDeployer) createSdkClient(accessKeyId, accessKeySecret, region string) (*alb20200616.Client, error) {
if region == "" {
region = "cn-hangzhou" // ALB 服务默认区域:华东一杭州
}
aConfig := &openapi.Config{
AccessKeyId: tea.String(accessKeyId),
AccessKeySecret: tea.String(accessKeySecret),
}
var endpoint string
switch region {
case "cn-hangzhou-finance":
endpoint = "alb.cn-hangzhou.aliyuncs.com"
default:
endpoint = fmt.Sprintf("alb.%s.aliyuncs.com", region)
}
aConfig.Endpoint = tea.String(endpoint)
client, err := alb20200616.NewClient(aConfig)
if err != nil {
return nil, err
}
return client, nil
}
func (d *AliyunALBDeployer) deployToLoadbalancer(ctx context.Context) error {
aliLoadbalancerId := d.option.DeployConfig.GetConfigAsString("loadbalancerId")
if aliLoadbalancerId == "" {
return errors.New("`loadbalancerId` is required")
}
aliListenerIds := make([]string, 0)
// 查询负载均衡实例的详细信息
// REF: https://help.aliyun.com/zh/slb/application-load-balancer/developer-reference/api-alb-2020-06-16-getloadbalancerattribute
getLoadBalancerAttributeReq := &alb20200616.GetLoadBalancerAttributeRequest{
LoadBalancerId: tea.String(aliLoadbalancerId),
}
getLoadBalancerAttributeResp, err := d.sdkClient.GetLoadBalancerAttribute(getLoadBalancerAttributeReq)
if err != nil {
return fmt.Errorf("failed to execute sdk request 'alb.GetLoadBalancerAttribute': %w", err)
}
d.infos = append(d.infos, toStr("已查询到 ALB 负载均衡实例", getLoadBalancerAttributeResp))
// 查询 HTTPS 监听列表
// REF: https://help.aliyun.com/zh/slb/application-load-balancer/developer-reference/api-alb-2020-06-16-listlisteners
listListenersPage := 1
listListenersLimit := int32(100)
var listListenersToken *string = nil
for {
listListenersReq := &alb20200616.ListListenersRequest{
MaxResults: tea.Int32(listListenersLimit),
NextToken: listListenersToken,
LoadBalancerIds: []*string{tea.String(aliLoadbalancerId)},
ListenerProtocol: tea.String("HTTPS"),
}
listListenersResp, err := d.sdkClient.ListListeners(listListenersReq)
if err != nil {
return fmt.Errorf("failed to execute sdk request 'alb.ListListeners': %w", err)
}
if listListenersResp.Body.Listeners != nil {
for _, listener := range listListenersResp.Body.Listeners {
aliListenerIds = append(aliListenerIds, *listener.ListenerId)
}
}
if listListenersResp.Body.NextToken == nil {
break
} else {
listListenersToken = listListenersResp.Body.NextToken
listListenersPage += 1
}
}
d.infos = append(d.infos, toStr("已查询到 ALB 负载均衡实例下的全部 HTTPS 监听", aliListenerIds))
// 查询 QUIC 监听列表
// REF: https://help.aliyun.com/zh/slb/application-load-balancer/developer-reference/api-alb-2020-06-16-listlisteners
listListenersPage = 1
listListenersToken = nil
for {
listListenersReq := &alb20200616.ListListenersRequest{
MaxResults: tea.Int32(listListenersLimit),
NextToken: listListenersToken,
LoadBalancerIds: []*string{tea.String(aliLoadbalancerId)},
ListenerProtocol: tea.String("QUIC"),
}
listListenersResp, err := d.sdkClient.ListListeners(listListenersReq)
if err != nil {
return fmt.Errorf("failed to execute sdk request 'alb.ListListeners': %w", err)
}
if listListenersResp.Body.Listeners != nil {
for _, listener := range listListenersResp.Body.Listeners {
aliListenerIds = append(aliListenerIds, *listener.ListenerId)
}
}
if listListenersResp.Body.NextToken == nil {
break
} else {
listListenersToken = listListenersResp.Body.NextToken
listListenersPage += 1
}
}
d.infos = append(d.infos, toStr("已查询到 ALB 负载均衡实例下的全部 QUIC 监听", aliListenerIds))
// 上传证书到 SSL
uploadResult, err := d.sslUploader.Upload(ctx, d.option.Certificate.Certificate, d.option.Certificate.PrivateKey)
if err != nil {
return err
}
d.infos = append(d.infos, toStr("已上传证书", uploadResult))
// 批量更新监听证书
var errs []error
for _, aliListenerId := range aliListenerIds {
if err := d.updateListenerCertificate(ctx, aliListenerId, uploadResult.CertId); err != nil {
errs = append(errs, err)
}
}
if len(errs) > 0 {
return errors.Join(errs...)
}
return nil
}
func (d *AliyunALBDeployer) deployToListener(ctx context.Context) error {
aliListenerId := d.option.DeployConfig.GetConfigAsString("listenerId")
if aliListenerId == "" {
return errors.New("`listenerId` is required")
}
// 上传证书到 SSL
uploadResult, err := d.sslUploader.Upload(ctx, d.option.Certificate.Certificate, d.option.Certificate.PrivateKey)
if err != nil {
return err
}
d.infos = append(d.infos, toStr("已上传证书", uploadResult))
// 更新监听
if err := d.updateListenerCertificate(ctx, aliListenerId, uploadResult.CertId); err != nil {
return err
}
return nil
}
func (d *AliyunALBDeployer) updateListenerCertificate(ctx context.Context, aliListenerId string, aliCertId string) error {
// 查询监听的属性
// REF: https://help.aliyun.com/zh/slb/application-load-balancer/developer-reference/api-alb-2020-06-16-getlistenerattribute
getListenerAttributeReq := &alb20200616.GetListenerAttributeRequest{
ListenerId: tea.String(aliListenerId),
}
getListenerAttributeResp, err := d.sdkClient.GetListenerAttribute(getListenerAttributeReq)
if err != nil {
return fmt.Errorf("failed to execute sdk request 'alb.GetListenerAttribute': %w", err)
}
d.infos = append(d.infos, toStr("已查询到 ALB 监听配置", getListenerAttributeResp))
// 修改监听的属性
// REF: https://help.aliyun.com/zh/slb/application-load-balancer/developer-reference/api-alb-2020-06-16-updatelistenerattribute
updateListenerAttributeReq := &alb20200616.UpdateListenerAttributeRequest{
ListenerId: tea.String(aliListenerId),
Certificates: []*alb20200616.UpdateListenerAttributeRequestCertificates{{
CertificateId: tea.String(aliCertId),
}},
}
updateListenerAttributeResp, err := d.sdkClient.UpdateListenerAttribute(updateListenerAttributeReq)
if err != nil {
return fmt.Errorf("failed to execute sdk request 'alb.UpdateListenerAttribute': %w", err)
}
d.infos = append(d.infos, toStr("已更新 ALB 监听配置", updateListenerAttributeResp))
return nil
}

View File

@@ -0,0 +1,282 @@
package deployer
import (
"context"
"encoding/json"
"errors"
"fmt"
openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client"
slb20140515 "github.com/alibabacloud-go/slb-20140515/v4/client"
"github.com/alibabacloud-go/tea/tea"
"github.com/usual2970/certimate/internal/domain"
"github.com/usual2970/certimate/internal/pkg/core/uploader"
)
type AliyunCLBDeployer struct {
option *DeployerOption
infos []string
sdkClient *slb20140515.Client
sslUploader uploader.Uploader
}
func NewAliyunCLBDeployer(option *DeployerOption) (Deployer, error) {
access := &domain.AliyunAccess{}
json.Unmarshal([]byte(option.Access), access)
client, err := (&AliyunCLBDeployer{}).createSdkClient(
access.AccessKeyId,
access.AccessKeySecret,
option.DeployConfig.GetConfigAsString("region"),
)
if err != nil {
return nil, err
}
uploader, err := uploader.NewAliyunSLBUploader(&uploader.AliyunSLBUploaderConfig{
AccessKeyId: access.AccessKeyId,
AccessKeySecret: access.AccessKeySecret,
Region: option.DeployConfig.GetConfigAsString("region"),
})
if err != nil {
return nil, err
}
return &AliyunCLBDeployer{
option: option,
infos: make([]string, 0),
sdkClient: client,
sslUploader: uploader,
}, nil
}
func (d *AliyunCLBDeployer) GetID() string {
return fmt.Sprintf("%s-%s", d.option.AccessRecord.GetString("name"), d.option.AccessRecord.Id)
}
func (d *AliyunCLBDeployer) GetInfo() []string {
return d.infos
}
func (d *AliyunCLBDeployer) Deploy(ctx context.Context) error {
switch d.option.DeployConfig.GetConfigAsString("resourceType") {
case "loadbalancer":
if err := d.deployToLoadbalancer(ctx); err != nil {
return err
}
case "listener":
if err := d.deployToListener(ctx); err != nil {
return err
}
default:
return errors.New("unsupported resource type")
}
return nil
}
func (d *AliyunCLBDeployer) createSdkClient(accessKeyId, accessKeySecret, region string) (*slb20140515.Client, error) {
if region == "" {
region = "cn-hangzhou" // CLB(SLB) 服务默认区域:华东一杭州
}
aConfig := &openapi.Config{
AccessKeyId: tea.String(accessKeyId),
AccessKeySecret: tea.String(accessKeySecret),
}
var endpoint string
switch region {
case "cn-hangzhou":
case "cn-hangzhou-finance":
case "cn-shanghai-finance-1":
case "cn-shenzhen-finance-1":
endpoint = "slb.aliyuncs.com"
default:
endpoint = fmt.Sprintf("slb.%s.aliyuncs.com", region)
}
aConfig.Endpoint = tea.String(endpoint)
client, err := slb20140515.NewClient(aConfig)
if err != nil {
return nil, err
}
return client, nil
}
func (d *AliyunCLBDeployer) deployToLoadbalancer(ctx context.Context) error {
aliLoadbalancerId := d.option.DeployConfig.GetConfigAsString("loadbalancerId")
if aliLoadbalancerId == "" {
return errors.New("`loadbalancerId` is required")
}
aliListenerPorts := make([]int32, 0)
// 查询负载均衡实例的详细信息
// REF: https://help.aliyun.com/zh/slb/classic-load-balancer/developer-reference/api-slb-2014-05-15-describeloadbalancerattribute
describeLoadBalancerAttributeReq := &slb20140515.DescribeLoadBalancerAttributeRequest{
RegionId: tea.String(d.option.DeployConfig.GetConfigAsString("region")),
LoadBalancerId: tea.String(aliLoadbalancerId),
}
describeLoadBalancerAttributeResp, err := d.sdkClient.DescribeLoadBalancerAttribute(describeLoadBalancerAttributeReq)
if err != nil {
return fmt.Errorf("failed to execute sdk request 'slb.DescribeLoadBalancerAttribute': %w", err)
}
d.infos = append(d.infos, toStr("已查询到 CLB 负载均衡实例", describeLoadBalancerAttributeResp))
// 查询 HTTPS 监听列表
// REF: https://help.aliyun.com/zh/slb/classic-load-balancer/developer-reference/api-slb-2014-05-15-describeloadbalancerlisteners
listListenersPage := 1
listListenersLimit := int32(100)
var listListenersToken *string = nil
for {
describeLoadBalancerListenersReq := &slb20140515.DescribeLoadBalancerListenersRequest{
RegionId: tea.String(d.option.DeployConfig.GetConfigAsString("region")),
MaxResults: tea.Int32(listListenersLimit),
NextToken: listListenersToken,
LoadBalancerId: []*string{tea.String(aliLoadbalancerId)},
ListenerProtocol: tea.String("https"),
}
describeLoadBalancerListenersResp, err := d.sdkClient.DescribeLoadBalancerListeners(describeLoadBalancerListenersReq)
if err != nil {
return fmt.Errorf("failed to execute sdk request 'slb.DescribeLoadBalancerListeners': %w", err)
}
if describeLoadBalancerListenersResp.Body.Listeners != nil {
for _, listener := range describeLoadBalancerListenersResp.Body.Listeners {
aliListenerPorts = append(aliListenerPorts, *listener.ListenerPort)
}
}
if describeLoadBalancerListenersResp.Body.NextToken == nil {
break
} else {
listListenersToken = describeLoadBalancerListenersResp.Body.NextToken
listListenersPage += 1
}
}
d.infos = append(d.infos, toStr("已查询到 CLB 负载均衡实例下的全部 HTTPS 监听", aliListenerPorts))
// 上传证书到 SLB
uploadResult, err := d.sslUploader.Upload(ctx, d.option.Certificate.Certificate, d.option.Certificate.PrivateKey)
if err != nil {
return err
}
d.infos = append(d.infos, toStr("已上传证书", uploadResult))
// 批量更新监听证书
var errs []error
for _, aliListenerPort := range aliListenerPorts {
if err := d.updateListenerCertificate(ctx, aliLoadbalancerId, aliListenerPort, uploadResult.CertId); err != nil {
errs = append(errs, err)
}
}
if len(errs) > 0 {
return errors.Join(errs...)
}
return nil
}
func (d *AliyunCLBDeployer) deployToListener(ctx context.Context) error {
aliLoadbalancerId := d.option.DeployConfig.GetConfigAsString("loadbalancerId")
if aliLoadbalancerId == "" {
return errors.New("`loadbalancerId` is required")
}
aliListenerPort := d.option.DeployConfig.GetConfigAsInt32("listenerPort")
if aliListenerPort == 0 {
return errors.New("`listenerPort` is required")
}
// 上传证书到 SLB
uploadResult, err := d.sslUploader.Upload(ctx, d.option.Certificate.Certificate, d.option.Certificate.PrivateKey)
if err != nil {
return err
}
d.infos = append(d.infos, toStr("已上传证书", uploadResult))
// 更新监听
if err := d.updateListenerCertificate(ctx, aliLoadbalancerId, aliListenerPort, uploadResult.CertId); err != nil {
return err
}
return nil
}
func (d *AliyunCLBDeployer) updateListenerCertificate(ctx context.Context, aliLoadbalancerId string, aliListenerPort int32, aliCertId string) error {
// 查询监听配置
// REF: https://help.aliyun.com/zh/slb/classic-load-balancer/developer-reference/api-slb-2014-05-15-describeloadbalancerhttpslistenerattribute
describeLoadBalancerHTTPSListenerAttributeReq := &slb20140515.DescribeLoadBalancerHTTPSListenerAttributeRequest{
LoadBalancerId: tea.String(aliLoadbalancerId),
ListenerPort: tea.Int32(aliListenerPort),
}
describeLoadBalancerHTTPSListenerAttributeResp, err := d.sdkClient.DescribeLoadBalancerHTTPSListenerAttribute(describeLoadBalancerHTTPSListenerAttributeReq)
if err != nil {
return fmt.Errorf("failed to execute sdk request 'slb.DescribeLoadBalancerHTTPSListenerAttribute': %w", err)
}
d.infos = append(d.infos, toStr("已查询到 CLB HTTPS 监听配置", describeLoadBalancerHTTPSListenerAttributeResp))
// 查询扩展域名
// REF: https://help.aliyun.com/zh/slb/classic-load-balancer/developer-reference/api-slb-2014-05-15-describedomainextensions
describeDomainExtensionsReq := &slb20140515.DescribeDomainExtensionsRequest{
RegionId: tea.String(d.option.DeployConfig.GetConfigAsString("region")),
LoadBalancerId: tea.String(aliLoadbalancerId),
ListenerPort: tea.Int32(aliListenerPort),
}
describeDomainExtensionsResp, err := d.sdkClient.DescribeDomainExtensions(describeDomainExtensionsReq)
if err != nil {
return fmt.Errorf("failed to execute sdk request 'slb.DescribeDomainExtensions': %w", err)
}
d.infos = append(d.infos, toStr("已查询到 CLB 扩展域名", describeDomainExtensionsResp))
// 遍历修改扩展域名
// REF: https://help.aliyun.com/zh/slb/classic-load-balancer/developer-reference/api-slb-2014-05-15-setdomainextensionattribute
//
// 这里仅修改跟被替换证书一致的扩展域名
if describeDomainExtensionsResp.Body.DomainExtensions == nil && describeDomainExtensionsResp.Body.DomainExtensions.DomainExtension == nil {
for _, domainExtension := range describeDomainExtensionsResp.Body.DomainExtensions.DomainExtension {
if *domainExtension.ServerCertificateId == *describeLoadBalancerHTTPSListenerAttributeResp.Body.ServerCertificateId {
break
}
setDomainExtensionAttributeReq := &slb20140515.SetDomainExtensionAttributeRequest{
RegionId: tea.String(d.option.DeployConfig.GetConfigAsString("region")),
DomainExtensionId: tea.String(*domainExtension.DomainExtensionId),
ServerCertificateId: tea.String(aliCertId),
}
_, err := d.sdkClient.SetDomainExtensionAttribute(setDomainExtensionAttributeReq)
if err != nil {
return fmt.Errorf("failed to execute sdk request 'slb.SetDomainExtensionAttribute': %w", err)
}
}
}
// 修改监听配置
// REF: https://help.aliyun.com/zh/slb/classic-load-balancer/developer-reference/api-slb-2014-05-15-setloadbalancerhttpslistenerattribute
//
// 注意修改监听配置要放在修改扩展域名之后
setLoadBalancerHTTPSListenerAttributeReq := &slb20140515.SetLoadBalancerHTTPSListenerAttributeRequest{
RegionId: tea.String(d.option.DeployConfig.GetConfigAsString("region")),
LoadBalancerId: tea.String(aliLoadbalancerId),
ListenerPort: tea.Int32(aliListenerPort),
ServerCertificateId: tea.String(aliCertId),
}
setLoadBalancerHTTPSListenerAttributeResp, err := d.sdkClient.SetLoadBalancerHTTPSListenerAttribute(setLoadBalancerHTTPSListenerAttributeReq)
if err != nil {
return fmt.Errorf("failed to execute sdk request 'slb.SetLoadBalancerHTTPSListenerAttribute': %w", err)
}
d.infos = append(d.infos, toStr("已更新 CLB HTTPS 监听配置", setLoadBalancerHTTPSListenerAttributeResp))
return nil
}

View File

@@ -0,0 +1,229 @@
package deployer
import (
"context"
"encoding/json"
"errors"
"fmt"
openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client"
nlb20220430 "github.com/alibabacloud-go/nlb-20220430/v2/client"
"github.com/alibabacloud-go/tea/tea"
"github.com/usual2970/certimate/internal/domain"
"github.com/usual2970/certimate/internal/pkg/core/uploader"
)
type AliyunNLBDeployer struct {
option *DeployerOption
infos []string
sdkClient *nlb20220430.Client
sslUploader uploader.Uploader
}
func NewAliyunNLBDeployer(option *DeployerOption) (Deployer, error) {
access := &domain.AliyunAccess{}
json.Unmarshal([]byte(option.Access), access)
client, err := (&AliyunNLBDeployer{}).createSdkClient(
access.AccessKeyId,
access.AccessKeySecret,
option.DeployConfig.GetConfigAsString("region"),
)
if err != nil {
return nil, err
}
uploader, err := uploader.NewAliyunCASUploader(&uploader.AliyunCASUploaderConfig{
AccessKeyId: access.AccessKeyId,
AccessKeySecret: access.AccessKeySecret,
Region: option.DeployConfig.GetConfigAsString("region"),
})
if err != nil {
return nil, err
}
return &AliyunNLBDeployer{
option: option,
infos: make([]string, 0),
sdkClient: client,
sslUploader: uploader,
}, nil
}
func (d *AliyunNLBDeployer) GetID() string {
return fmt.Sprintf("%s-%s", d.option.AccessRecord.GetString("name"), d.option.AccessRecord.Id)
}
func (d *AliyunNLBDeployer) GetInfo() []string {
return d.infos
}
func (d *AliyunNLBDeployer) Deploy(ctx context.Context) error {
switch d.option.DeployConfig.GetConfigAsString("resourceType") {
case "loadbalancer":
if err := d.deployToLoadbalancer(ctx); err != nil {
return err
}
case "listener":
if err := d.deployToListener(ctx); err != nil {
return err
}
default:
return errors.New("unsupported resource type")
}
return nil
}
func (d *AliyunNLBDeployer) createSdkClient(accessKeyId, accessKeySecret, region string) (*nlb20220430.Client, error) {
if region == "" {
region = "cn-hangzhou" // NLB 服务默认区域:华东一杭州
}
aConfig := &openapi.Config{
AccessKeyId: tea.String(accessKeyId),
AccessKeySecret: tea.String(accessKeySecret),
}
var endpoint string
switch region {
default:
endpoint = fmt.Sprintf("nlb.%s.aliyuncs.com", region)
}
aConfig.Endpoint = tea.String(endpoint)
client, err := nlb20220430.NewClient(aConfig)
if err != nil {
return nil, err
}
return client, nil
}
func (d *AliyunNLBDeployer) deployToLoadbalancer(ctx context.Context) error {
aliLoadbalancerId := d.option.DeployConfig.GetConfigAsString("loadbalancerId")
if aliLoadbalancerId == "" {
return errors.New("`loadbalancerId` is required")
}
aliListenerIds := make([]string, 0)
// 查询负载均衡实例的详细信息
// REF: https://help.aliyun.com/zh/slb/network-load-balancer/developer-reference/api-nlb-2022-04-30-getloadbalancerattribute
getLoadBalancerAttributeReq := &nlb20220430.GetLoadBalancerAttributeRequest{
LoadBalancerId: tea.String(aliLoadbalancerId),
}
getLoadBalancerAttributeResp, err := d.sdkClient.GetLoadBalancerAttribute(getLoadBalancerAttributeReq)
if err != nil {
return fmt.Errorf("failed to execute sdk request 'nlb.GetLoadBalancerAttribute': %w", err)
}
d.infos = append(d.infos, toStr("已查询到 NLB 负载均衡实例", getLoadBalancerAttributeResp))
// 查询 TCPSSL 监听列表
// REF: https://help.aliyun.com/zh/slb/network-load-balancer/developer-reference/api-nlb-2022-04-30-listlisteners
listListenersPage := 1
listListenersLimit := int32(100)
var listListenersToken *string = nil
for {
listListenersReq := &nlb20220430.ListListenersRequest{
MaxResults: tea.Int32(listListenersLimit),
NextToken: listListenersToken,
LoadBalancerIds: []*string{tea.String(aliLoadbalancerId)},
ListenerProtocol: tea.String("TCPSSL"),
}
listListenersResp, err := d.sdkClient.ListListeners(listListenersReq)
if err != nil {
return fmt.Errorf("failed to execute sdk request 'nlb.ListListeners': %w", err)
}
if listListenersResp.Body.Listeners != nil {
for _, listener := range listListenersResp.Body.Listeners {
aliListenerIds = append(aliListenerIds, *listener.ListenerId)
}
}
if listListenersResp.Body.NextToken == nil {
break
} else {
listListenersToken = listListenersResp.Body.NextToken
listListenersPage += 1
}
}
d.infos = append(d.infos, toStr("已查询到 NLB 负载均衡实例下的全部 TCPSSL 监听", aliListenerIds))
// 上传证书到 SSL
uploadResult, err := d.sslUploader.Upload(ctx, d.option.Certificate.Certificate, d.option.Certificate.PrivateKey)
if err != nil {
return err
}
d.infos = append(d.infos, toStr("已上传证书", uploadResult))
// 批量更新监听证书
var errs []error
for _, aliListenerId := range aliListenerIds {
if err := d.updateListenerCertificate(ctx, aliListenerId, uploadResult.CertId); err != nil {
errs = append(errs, err)
}
}
if len(errs) > 0 {
return errors.Join(errs...)
}
return nil
}
func (d *AliyunNLBDeployer) deployToListener(ctx context.Context) error {
aliListenerId := d.option.DeployConfig.GetConfigAsString("listenerId")
if aliListenerId == "" {
return errors.New("`listenerId` is required")
}
// 上传证书到 SSL
uploadResult, err := d.sslUploader.Upload(ctx, d.option.Certificate.Certificate, d.option.Certificate.PrivateKey)
if err != nil {
return err
}
d.infos = append(d.infos, toStr("已上传证书", uploadResult))
// 更新监听
if err := d.updateListenerCertificate(ctx, aliListenerId, uploadResult.CertId); err != nil {
return err
}
return nil
}
func (d *AliyunNLBDeployer) updateListenerCertificate(ctx context.Context, aliListenerId string, aliCertId string) error {
// 查询监听的属性
// REF: https://help.aliyun.com/zh/slb/network-load-balancer/developer-reference/api-nlb-2022-04-30-getlistenerattribute
getListenerAttributeReq := &nlb20220430.GetListenerAttributeRequest{
ListenerId: tea.String(aliListenerId),
}
getListenerAttributeResp, err := d.sdkClient.GetListenerAttribute(getListenerAttributeReq)
if err != nil {
return fmt.Errorf("failed to execute sdk request 'nlb.GetListenerAttribute': %w", err)
}
d.infos = append(d.infos, toStr("已查询到 NLB 监听配置", getListenerAttributeResp))
// 修改监听的属性
// REF: https://help.aliyun.com/zh/slb/network-load-balancer/developer-reference/api-nlb-2022-04-30-updatelistenerattribute
updateListenerAttributeReq := &nlb20220430.UpdateListenerAttributeRequest{
ListenerId: tea.String(aliListenerId),
CertificateIds: []*string{tea.String(aliCertId)},
}
updateListenerAttributeResp, err := d.sdkClient.UpdateListenerAttribute(updateListenerAttributeReq)
if err != nil {
return fmt.Errorf("failed to execute sdk request 'nlb.UpdateListenerAttribute': %w", err)
}
d.infos = append(d.infos, toStr("已更新 NLB 监听配置", updateListenerAttributeResp))
return nil
}

View File

@@ -1,16 +1,21 @@
package deployer
import (
"bytes"
"context"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"strings"
"time"
"github.com/pavlo-v-chernykh/keystore-go/v4"
"github.com/pocketbase/pocketbase/models"
"software.sslmate.com/src/go-pkcs12"
"github.com/usual2970/certimate/internal/applicant"
"github.com/usual2970/certimate/internal/domain"
"github.com/usual2970/certimate/internal/pkg/utils/x509"
"github.com/usual2970/certimate/internal/utils/app"
)
@@ -18,10 +23,16 @@ const (
targetAliyunOSS = "aliyun-oss"
targetAliyunCDN = "aliyun-cdn"
targetAliyunESA = "aliyun-dcdn"
targetAliyunCLB = "aliyun-clb"
targetAliyunALB = "aliyun-alb"
targetAliyunNLB = "aliyun-nlb"
targetTencentCDN = "tencent-cdn"
targetTencentECDN = "tencent-ecdn"
targetTencentCLB = "tencent-clb"
targetTencentCOS = "tencent-cos"
targetTencentTEO = "tencent-teo"
targetHuaweiCloudCDN = "huaweicloud-cdn"
targetHuaweiCloudELB = "huaweicloud-elb"
targetQiniuCdn = "qiniu-cdn"
targetLocal = "local"
targetSSH = "ssh"
@@ -32,7 +43,6 @@ const (
type DeployerOption struct {
DomainId string `json:"domainId"`
Domain string `json:"domain"`
Product string `json:"product"`
Access string `json:"access"`
AccessRecord *models.Record `json:"-"`
DeployConfig domain.DeployConfig `json:"deployConfig"`
@@ -84,7 +94,6 @@ func getWithDeployConfig(record *models.Record, cert *applicant.Certificate, dep
option := &DeployerOption{
DomainId: record.Id,
Domain: record.GetString("domain"),
Product: getProduct(deployConfig.Type),
Access: access.GetString("config"),
AccessRecord: access,
DeployConfig: deployConfig,
@@ -105,14 +114,26 @@ func getWithDeployConfig(record *models.Record, cert *applicant.Certificate, dep
return NewAliyunCDNDeployer(option)
case targetAliyunESA:
return NewAliyunESADeployer(option)
case targetAliyunCLB:
return NewAliyunCLBDeployer(option)
case targetAliyunALB:
return NewAliyunALBDeployer(option)
case targetAliyunNLB:
return NewAliyunNLBDeployer(option)
case targetTencentCDN:
return NewTencentCDNDeployer(option)
case targetTencentECDN:
return NewTencentECDNDeployer(option)
case targetTencentCLB:
return NewTencentCLBDeployer(option)
case targetTencentCOS:
return NewTencentCOSDeployer(option)
case targetTencentTEO:
return NewTencentTEODeployer(option)
case targetHuaweiCloudCDN:
return NewHuaweiCloudCDNDeployer(option)
case targetHuaweiCloudELB:
return NewHuaweiCloudELBDeployer(option)
case targetQiniuCdn:
return NewQiniuCDNDeployer(option)
case targetLocal:
@@ -124,15 +145,7 @@ func getWithDeployConfig(record *models.Record, cert *applicant.Certificate, dep
case targetK8sSecret:
return NewK8sSecretDeployer(option)
}
return nil, errors.New("not implemented")
}
func getProduct(t string) string {
rs := strings.Split(t, "-")
if len(rs) < 2 {
return ""
}
return rs[1]
return nil, errors.New("unsupported deploy target")
}
func toStr(tag string, data any) string {
@@ -177,3 +190,57 @@ func getDeployVariables(conf domain.DeployConfig) map[string]string {
return rs
}
func convertPEMToPFX(certificate string, privateKey string, password string) ([]byte, error) {
cert, err := x509.ParseCertificateFromPEM(certificate)
if err != nil {
return nil, err
}
privkey, err := x509.ParsePKCS1PrivateKeyFromPEM(privateKey)
if err != nil {
return nil, err
}
pfxData, err := pkcs12.LegacyRC2.Encode(privkey, cert, nil, password)
if err != nil {
return nil, fmt.Errorf("failed to encode as pfx %w", err)
}
return pfxData, nil
}
func convertPEMToJKS(certificate string, privateKey string, alias string, keypass string, storepass string) ([]byte, error) {
certBlock, _ := pem.Decode([]byte(certificate))
if certBlock == nil {
return nil, errors.New("failed to decode certificate PEM")
}
privkeyBlock, _ := pem.Decode([]byte(privateKey))
if privkeyBlock == nil {
return nil, errors.New("failed to decode private key PEM")
}
ks := keystore.New()
entry := keystore.PrivateKeyEntry{
CreationTime: time.Now(),
PrivateKey: privkeyBlock.Bytes,
CertificateChain: []keystore.Certificate{
{
Type: "X509",
Content: certBlock.Bytes,
},
},
}
if err := ks.SetPrivateKeyEntry(alias, entry, []byte(keypass)); err != nil {
return nil, err
}
var buf bytes.Buffer
if err := ks.Store(&buf, []byte(storepass)); err != nil {
return nil, err
}
return buf.Bytes(), nil
}

View File

@@ -7,24 +7,53 @@ import (
"time"
"github.com/huaweicloud/huaweicloud-sdk-go-v3/core/auth/global"
cdn "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/cdn/v2"
cdnModel "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/cdn/v2/model"
cdnRegion "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/cdn/v2/region"
hcCdn "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/cdn/v2"
hcCdnModel "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/cdn/v2/model"
hcCdnRegion "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/cdn/v2/region"
"github.com/usual2970/certimate/internal/domain"
uploader "github.com/usual2970/certimate/internal/pkg/core/uploader"
"github.com/usual2970/certimate/internal/pkg/core/uploader"
"github.com/usual2970/certimate/internal/pkg/utils/cast"
)
type HuaweiCloudCDNDeployer struct {
option *DeployerOption
infos []string
sdkClient *hcCdn.CdnClient
sslUploader uploader.Uploader
}
func NewHuaweiCloudCDNDeployer(option *DeployerOption) (Deployer, error) {
access := &domain.HuaweiCloudAccess{}
if err := json.Unmarshal([]byte(option.Access), access); err != nil {
return nil, err
}
client, err := (&HuaweiCloudCDNDeployer{}).createSdkClient(
access.AccessKeyId,
access.SecretAccessKey,
option.DeployConfig.GetConfigAsString("region"),
)
if err != nil {
return nil, err
}
// TODO: SCM 服务与 DNS 服务所支持的区域可能不一致,这里暂时不传而是使用默认值,仅支持华为云国内版
uploader, err := uploader.NewHuaweiCloudSCMUploader(&uploader.HuaweiCloudSCMUploaderConfig{
AccessKeyId: access.AccessKeyId,
SecretAccessKey: access.SecretAccessKey,
Region: "",
})
if err != nil {
return nil, err
}
return &HuaweiCloudCDNDeployer{
option: option,
infos: make([]string, 0),
option: option,
infos: make([]string, 0),
sdkClient: client,
sslUploader: uploader,
}, nil
}
@@ -37,25 +66,12 @@ func (d *HuaweiCloudCDNDeployer) GetInfo() []string {
}
func (d *HuaweiCloudCDNDeployer) Deploy(ctx context.Context) error {
access := &domain.HuaweiCloudAccess{}
if err := json.Unmarshal([]byte(d.option.Access), access); err != nil {
return err
}
// TODO: CDN 服务与 DNS 服务所支持的区域可能不一致,这里暂时不传而是使用默认值,仅支持华为云国内版
client, err := d.createClient("", access.AccessKeyId, access.SecretAccessKey)
if err != nil {
return err
}
d.infos = append(d.infos, toStr("SDK 客户端创建成功", nil))
// 查询加速域名配置
// REF: https://support.huaweicloud.com/api-cdn/ShowDomainFullConfig.html
showDomainFullConfigReq := &cdnModel.ShowDomainFullConfigRequest{
showDomainFullConfigReq := &hcCdnModel.ShowDomainFullConfigRequest{
DomainName: d.option.DeployConfig.GetConfigAsString("domain"),
}
showDomainFullConfigResp, err := client.ShowDomainFullConfig(showDomainFullConfigReq)
showDomainFullConfigResp, err := d.sdkClient.ShowDomainFullConfig(showDomainFullConfigReq)
if err != nil {
return err
}
@@ -68,19 +84,10 @@ func (d *HuaweiCloudCDNDeployer) Deploy(ctx context.Context) error {
updateDomainMultiCertificatesReqBodyContent := &huaweicloudCDNUpdateDomainMultiCertificatesRequestBodyContent{}
updateDomainMultiCertificatesReqBodyContent.DomainName = d.option.DeployConfig.GetConfigAsString("domain")
updateDomainMultiCertificatesReqBodyContent.HttpsSwitch = 1
var updateDomainMultiCertificatesResp *cdnModel.UpdateDomainMultiCertificatesResponse
var updateDomainMultiCertificatesResp *hcCdnModel.UpdateDomainMultiCertificatesResponse
if d.option.DeployConfig.GetConfigAsBool("useSCM") {
uploader, err := uploader.NewHuaweiCloudSCMUploader(&uploader.HuaweiCloudSCMUploaderConfig{
Region: "", // TODO: SCM 服务与 DNS 服务所支持的区域可能不一致,这里暂时不传而是使用默认值,仅支持华为云国内版
AccessKeyId: access.AccessKeyId,
SecretAccessKey: access.SecretAccessKey,
})
if err != nil {
return err
}
// 上传证书到 SCM
uploadResult, err := uploader.Upload(ctx, d.option.Certificate.Certificate, d.option.Certificate.PrivateKey)
uploadResult, err := d.sslUploader.Upload(ctx, d.option.Certificate.Certificate, d.option.Certificate.PrivateKey)
if err != nil {
return err
}
@@ -102,7 +109,7 @@ func (d *HuaweiCloudCDNDeployer) Deploy(ctx context.Context) error {
Https: updateDomainMultiCertificatesReqBodyContent,
},
}
updateDomainMultiCertificatesResp, err = executeHuaweiCloudCDNUploadDomainMultiCertificates(client, updateDomainMultiCertificatesReq)
updateDomainMultiCertificatesResp, err = executeHuaweiCloudCDNUploadDomainMultiCertificates(d.sdkClient, updateDomainMultiCertificatesReq)
if err != nil {
return err
}
@@ -112,7 +119,11 @@ func (d *HuaweiCloudCDNDeployer) Deploy(ctx context.Context) error {
return nil
}
func (d *HuaweiCloudCDNDeployer) createClient(region, accessKeyId, secretAccessKey string) (*cdn.CdnClient, error) {
func (d *HuaweiCloudCDNDeployer) createSdkClient(accessKeyId, secretAccessKey, region string) (*hcCdn.CdnClient, error) {
if region == "" {
region = "cn-north-1" // CDN 服务默认区域:华北一北京
}
auth, err := global.NewCredentialsBuilder().
WithAk(accessKeyId).
WithSk(secretAccessKey).
@@ -121,16 +132,12 @@ func (d *HuaweiCloudCDNDeployer) createClient(region, accessKeyId, secretAccessK
return nil, err
}
if region == "" {
region = "cn-north-1" // CDN 服务默认区域:华北一北京
}
hcRegion, err := cdnRegion.SafeValueOf(region)
hcRegion, err := hcCdnRegion.SafeValueOf(region)
if err != nil {
return nil, err
}
hcClient, err := cdn.CdnClientBuilder().
hcClient, err := hcCdn.CdnClientBuilder().
WithRegion(hcRegion).
WithCredential(auth).
SafeBuild()
@@ -138,12 +145,12 @@ func (d *HuaweiCloudCDNDeployer) createClient(region, accessKeyId, secretAccessK
return nil, err
}
client := cdn.NewCdnClient(hcClient)
client := hcCdn.NewCdnClient(hcClient)
return client, nil
}
type huaweicloudCDNUpdateDomainMultiCertificatesRequestBodyContent struct {
cdnModel.UpdateDomainMultiCertificatesRequestBodyContent `json:",inline"`
hcCdnModel.UpdateDomainMultiCertificatesRequestBodyContent `json:",inline"`
SCMCertificateId *string `json:"scm_certificate_id,omitempty"`
}
@@ -156,20 +163,20 @@ type huaweicloudCDNUpdateDomainMultiCertificatesRequest struct {
Body *huaweicloudCDNUpdateDomainMultiCertificatesRequestBody `json:"body,omitempty"`
}
func executeHuaweiCloudCDNUploadDomainMultiCertificates(client *cdn.CdnClient, request *huaweicloudCDNUpdateDomainMultiCertificatesRequest) (*cdnModel.UpdateDomainMultiCertificatesResponse, error) {
func executeHuaweiCloudCDNUploadDomainMultiCertificates(client *hcCdn.CdnClient, request *huaweicloudCDNUpdateDomainMultiCertificatesRequest) (*hcCdnModel.UpdateDomainMultiCertificatesResponse, error) {
// 华为云官方 SDK 中目前提供的字段缺失,这里暂时先需自定义请求
// 可能需要等之后 SDK 更新
requestDef := cdn.GenReqDefForUpdateDomainMultiCertificates()
requestDef := hcCdn.GenReqDefForUpdateDomainMultiCertificates()
if resp, err := client.HcClient.Sync(request, requestDef); err != nil {
return nil, err
} else {
return resp.(*cdnModel.UpdateDomainMultiCertificatesResponse), nil
return resp.(*hcCdnModel.UpdateDomainMultiCertificatesResponse), nil
}
}
func mergeHuaweiCloudCDNConfig(src *cdnModel.ConfigsGetBody, dest *huaweicloudCDNUpdateDomainMultiCertificatesRequestBodyContent) *huaweicloudCDNUpdateDomainMultiCertificatesRequestBodyContent {
func mergeHuaweiCloudCDNConfig(src *hcCdnModel.ConfigsGetBody, dest *huaweicloudCDNUpdateDomainMultiCertificatesRequestBodyContent) *huaweicloudCDNUpdateDomainMultiCertificatesRequestBodyContent {
if src == nil {
return dest
}
@@ -186,7 +193,7 @@ func mergeHuaweiCloudCDNConfig(src *cdnModel.ConfigsGetBody, dest *huaweicloudCD
}
if src.ForceRedirect != nil {
dest.ForceRedirectConfig = &cdnModel.ForceRedirect{}
dest.ForceRedirectConfig = &hcCdnModel.ForceRedirect{}
if src.ForceRedirect.Status == "on" {
dest.ForceRedirectConfig.Switch = 1

View File

@@ -0,0 +1,381 @@
package deployer
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"github.com/huaweicloud/huaweicloud-sdk-go-v3/core/auth/basic"
"github.com/huaweicloud/huaweicloud-sdk-go-v3/core/auth/global"
hcElb "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/elb/v3"
hcElbModel "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/elb/v3/model"
hcElbRegion "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/elb/v3/region"
hcIam "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/iam/v3"
hcIamModel "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/iam/v3/model"
hcIamRegion "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/iam/v3/region"
"github.com/usual2970/certimate/internal/domain"
"github.com/usual2970/certimate/internal/pkg/core/uploader"
"github.com/usual2970/certimate/internal/pkg/utils/cast"
)
type HuaweiCloudELBDeployer struct {
option *DeployerOption
infos []string
sdkClient *hcElb.ElbClient
sslUploader uploader.Uploader
}
func NewHuaweiCloudELBDeployer(option *DeployerOption) (Deployer, error) {
access := &domain.HuaweiCloudAccess{}
if err := json.Unmarshal([]byte(option.Access), access); err != nil {
return nil, err
}
client, err := (&HuaweiCloudELBDeployer{}).createSdkClient(
access.AccessKeyId,
access.SecretAccessKey,
option.DeployConfig.GetConfigAsString("region"),
)
if err != nil {
return nil, err
}
uploader, err := uploader.NewHuaweiCloudELBUploader(&uploader.HuaweiCloudELBUploaderConfig{
AccessKeyId: access.AccessKeyId,
SecretAccessKey: access.SecretAccessKey,
Region: option.DeployConfig.GetConfigAsString("region"),
})
if err != nil {
return nil, err
}
return &HuaweiCloudELBDeployer{
option: option,
infos: make([]string, 0),
sdkClient: client,
sslUploader: uploader,
}, nil
}
func (d *HuaweiCloudELBDeployer) GetID() string {
return fmt.Sprintf("%s-%s", d.option.AccessRecord.GetString("name"), d.option.AccessRecord.Id)
}
func (d *HuaweiCloudELBDeployer) GetInfo() []string {
return d.infos
}
func (d *HuaweiCloudELBDeployer) Deploy(ctx context.Context) error {
switch d.option.DeployConfig.GetConfigAsString("resourceType") {
case "certificate":
if err := d.deployToCertificate(ctx); err != nil {
return err
}
case "loadbalancer":
if err := d.deployToLoadbalancer(ctx); err != nil {
return err
}
case "listener":
if err := d.deployToListener(ctx); err != nil {
return err
}
default:
return errors.New("unsupported resource type")
}
return nil
}
func (d *HuaweiCloudELBDeployer) createSdkClient(accessKeyId, secretAccessKey, region string) (*hcElb.ElbClient, error) {
if region == "" {
region = "cn-north-4" // ELB 服务默认区域:华北四北京
}
projectId, err := (&HuaweiCloudELBDeployer{}).getSdkProjectId(
accessKeyId,
secretAccessKey,
region,
)
if err != nil {
return nil, err
}
auth, err := basic.NewCredentialsBuilder().
WithAk(accessKeyId).
WithSk(secretAccessKey).
WithProjectId(projectId).
SafeBuild()
if err != nil {
return nil, err
}
hcRegion, err := hcElbRegion.SafeValueOf(region)
if err != nil {
return nil, err
}
hcClient, err := hcElb.ElbClientBuilder().
WithRegion(hcRegion).
WithCredential(auth).
SafeBuild()
if err != nil {
return nil, err
}
client := hcElb.NewElbClient(hcClient)
return client, nil
}
func (u *HuaweiCloudELBDeployer) getSdkProjectId(accessKeyId, secretAccessKey, region string) (string, error) {
if region == "" {
region = "cn-north-4" // IAM 服务默认区域:华北四北京
}
auth, err := global.NewCredentialsBuilder().
WithAk(accessKeyId).
WithSk(secretAccessKey).
SafeBuild()
if err != nil {
return "", err
}
hcRegion, err := hcIamRegion.SafeValueOf(region)
if err != nil {
return "", err
}
hcClient, err := hcIam.IamClientBuilder().
WithRegion(hcRegion).
WithCredential(auth).
SafeBuild()
if err != nil {
return "", err
}
client := hcIam.NewIamClient(hcClient)
if err != nil {
return "", err
}
request := &hcIamModel.KeystoneListProjectsRequest{
Name: &region,
}
response, err := client.KeystoneListProjects(request)
if err != nil {
return "", err
} else if response.Projects == nil || len(*response.Projects) == 0 {
return "", fmt.Errorf("no project found")
}
return (*response.Projects)[0].Id, nil
}
func (d *HuaweiCloudELBDeployer) deployToCertificate(ctx context.Context) error {
hcCertId := d.option.DeployConfig.GetConfigAsString("certificateId")
if hcCertId == "" {
return errors.New("`certificateId` is required")
}
// 更新证书
// REF: https://support.huaweicloud.com/api-elb/UpdateCertificate.html
updateCertificateReq := &hcElbModel.UpdateCertificateRequest{
CertificateId: hcCertId,
Body: &hcElbModel.UpdateCertificateRequestBody{
Certificate: &hcElbModel.UpdateCertificateOption{
Certificate: cast.StringPtr(d.option.Certificate.Certificate),
PrivateKey: cast.StringPtr(d.option.Certificate.PrivateKey),
},
},
}
updateCertificateResp, err := d.sdkClient.UpdateCertificate(updateCertificateReq)
if err != nil {
return fmt.Errorf("failed to execute sdk request 'elb.UpdateCertificate': %w", err)
}
d.infos = append(d.infos, toStr("已更新 ELB 证书", updateCertificateResp))
return nil
}
func (d *HuaweiCloudELBDeployer) deployToLoadbalancer(ctx context.Context) error {
hcLoadbalancerId := d.option.DeployConfig.GetConfigAsString("loadbalancerId")
if hcLoadbalancerId == "" {
return errors.New("`loadbalancerId` is required")
}
hcListenerIds := make([]string, 0)
// 查询负载均衡器详情
// REF: https://support.huaweicloud.com/api-elb/ShowLoadBalancer.html
showLoadBalancerReq := &hcElbModel.ShowLoadBalancerRequest{
LoadbalancerId: hcLoadbalancerId,
}
showLoadBalancerResp, err := d.sdkClient.ShowLoadBalancer(showLoadBalancerReq)
if err != nil {
return fmt.Errorf("failed to execute sdk request 'elb.ShowLoadBalancer': %w", err)
}
d.infos = append(d.infos, toStr("已查询到 ELB 负载均衡器", showLoadBalancerResp))
// 查询监听器列表
// REF: https://support.huaweicloud.com/api-elb/ListListeners.html
listListenersLimit := int32(2000)
var listListenersMarker *string = nil
for {
listListenersReq := &hcElbModel.ListListenersRequest{
Limit: cast.Int32Ptr(listListenersLimit),
Marker: listListenersMarker,
Protocol: &[]string{"HTTPS", "TERMINATED_HTTPS"},
LoadbalancerId: &[]string{showLoadBalancerResp.Loadbalancer.Id},
}
listListenersResp, err := d.sdkClient.ListListeners(listListenersReq)
if err != nil {
return fmt.Errorf("failed to execute sdk request 'elb.ListListeners': %w", err)
}
if listListenersResp.Listeners != nil {
for _, listener := range *listListenersResp.Listeners {
hcListenerIds = append(hcListenerIds, listener.Id)
}
}
if listListenersResp.Listeners == nil || len(*listListenersResp.Listeners) < int(listListenersLimit) {
break
} else {
listListenersMarker = listListenersResp.PageInfo.NextMarker
}
}
d.infos = append(d.infos, toStr("已查询到 ELB 负载均衡器下的监听器", hcListenerIds))
// 上传证书到 SCM
uploadResult, err := d.sslUploader.Upload(ctx, d.option.Certificate.Certificate, d.option.Certificate.PrivateKey)
if err != nil {
return err
}
d.infos = append(d.infos, toStr("已上传证书", uploadResult))
// 批量更新监听器证书
var errs []error
for _, hcListenerId := range hcListenerIds {
if err := d.updateListenerCertificate(ctx, hcListenerId, uploadResult.CertId); err != nil {
errs = append(errs, err)
}
}
if len(errs) > 0 {
return errors.Join(errs...)
}
return nil
}
func (d *HuaweiCloudELBDeployer) deployToListener(ctx context.Context) error {
hcListenerId := d.option.DeployConfig.GetConfigAsString("listenerId")
if hcListenerId == "" {
return errors.New("`listenerId` is required")
}
// 上传证书到 SCM
uploadResult, err := d.sslUploader.Upload(ctx, d.option.Certificate.Certificate, d.option.Certificate.PrivateKey)
if err != nil {
return err
}
d.infos = append(d.infos, toStr("已上传证书", uploadResult))
// 更新监听器证书
if err := d.updateListenerCertificate(ctx, hcListenerId, uploadResult.CertId); err != nil {
return err
}
return nil
}
func (d *HuaweiCloudELBDeployer) updateListenerCertificate(ctx context.Context, hcListenerId string, hcCertId string) error {
// 查询监听器详情
// REF: https://support.huaweicloud.com/api-elb/ShowListener.html
showListenerReq := &hcElbModel.ShowListenerRequest{
ListenerId: hcListenerId,
}
showListenerResp, err := d.sdkClient.ShowListener(showListenerReq)
if err != nil {
return fmt.Errorf("failed to execute sdk request 'elb.ShowListener': %w", err)
}
d.infos = append(d.infos, toStr("已查询到 ELB 监听器", showListenerResp))
// 更新监听器
// REF: https://support.huaweicloud.com/api-elb/UpdateListener.html
updateListenerReq := &hcElbModel.UpdateListenerRequest{
ListenerId: hcListenerId,
Body: &hcElbModel.UpdateListenerRequestBody{
Listener: &hcElbModel.UpdateListenerOption{
DefaultTlsContainerRef: cast.StringPtr(hcCertId),
},
},
}
if showListenerResp.Listener.SniContainerRefs != nil {
if len(showListenerResp.Listener.SniContainerRefs) > 0 {
// 如果开启 SNI需替换同 SAN 的证书
sniCertIds := make([]string, 0)
sniCertIds = append(sniCertIds, hcCertId)
listOldCertificateReq := &hcElbModel.ListCertificatesRequest{
Id: &showListenerResp.Listener.SniContainerRefs,
}
listOldCertificateResp, err := d.sdkClient.ListCertificates(listOldCertificateReq)
if err != nil {
return fmt.Errorf("failed to execute sdk request 'elb.ListCertificates': %w", err)
}
showNewCertificateReq := &hcElbModel.ShowCertificateRequest{
CertificateId: hcCertId,
}
showNewCertificateResp, err := d.sdkClient.ShowCertificate(showNewCertificateReq)
if err != nil {
return fmt.Errorf("failed to execute sdk request 'elb.ShowCertificate': %w", err)
}
for _, certificate := range *listOldCertificateResp.Certificates {
oldCertificate := certificate
newCertificate := showNewCertificateResp.Certificate
if oldCertificate.SubjectAlternativeNames != nil && newCertificate.SubjectAlternativeNames != nil {
oldCertificateSans := oldCertificate.SubjectAlternativeNames
newCertificateSans := newCertificate.SubjectAlternativeNames
sort.Strings(*oldCertificateSans)
sort.Strings(*newCertificateSans)
if strings.Join(*oldCertificateSans, ";") == strings.Join(*newCertificateSans, ";") {
continue
}
} else {
if oldCertificate.Domain == newCertificate.Domain {
continue
}
}
sniCertIds = append(sniCertIds, certificate.Id)
}
updateListenerReq.Body.Listener.SniContainerRefs = &sniCertIds
}
if showListenerResp.Listener.SniMatchAlgo != "" {
updateListenerReq.Body.Listener.SniMatchAlgo = cast.StringPtr(showListenerResp.Listener.SniMatchAlgo)
}
}
updateListenerResp, err := d.sdkClient.UpdateListener(updateListenerReq)
if err != nil {
return fmt.Errorf("failed to execute sdk request 'elb.UpdateListener': %w", err)
}
d.infos = append(d.infos, toStr("已更新 ELB 监听器", updateListenerResp))
return nil
}

View File

@@ -9,6 +9,7 @@ import (
corev1 "k8s.io/api/core/v1"
k8sMetaV1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
"github.com/usual2970/certimate/internal/domain"
@@ -107,7 +108,7 @@ func (d *K8sSecretDeployer) Deploy(ctx context.Context) error {
}
// 更新 Secret 实例
_, err = client.CoreV1().Secrets(namespace).Update(ctx, &secretPayload, k8sMetaV1.UpdateOptions{})
_, err = client.CoreV1().Secrets(namespace).Update(context.TODO(), &secretPayload, k8sMetaV1.UpdateOptions{})
if err != nil {
return fmt.Errorf("failed to update k8s secret: %w", err)
}
@@ -118,11 +119,18 @@ func (d *K8sSecretDeployer) Deploy(ctx context.Context) error {
}
func (d *K8sSecretDeployer) createClient(access *domain.KubernetesAccess) (*kubernetes.Clientset, error) {
kubeConfig, err := clientcmd.NewClientConfigFromBytes([]byte(access.KubeConfig))
if err != nil {
return nil, err
var config *rest.Config
var err error
if access.KubeConfig == "" {
config, err = rest.InClusterConfig()
} else {
kubeConfig, err := clientcmd.NewClientConfigFromBytes([]byte(access.KubeConfig))
if err != nil {
return nil, err
}
config, err = kubeConfig.ClientConfig()
}
config, err := kubeConfig.ClientConfig()
if err != nil {
return nil, err
}
@@ -131,6 +139,5 @@ func (d *K8sSecretDeployer) createClient(access *domain.KubernetesAccess) (*kube
if err != nil {
return nil, err
}
return client, nil
}

View File

@@ -1,15 +1,15 @@
package deployer
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"github.com/usual2970/certimate/internal/domain"
"github.com/usual2970/certimate/internal/pkg/utils/fs"
)
type LocalDeployer struct {
@@ -17,6 +17,18 @@ type LocalDeployer struct {
infos []string
}
const (
certFormatPEM = "pem"
certFormatPFX = "pfx"
certFormatJKS = "jks"
)
const (
shellEnvSh = "sh"
shellEnvCmd = "cmd"
shellEnvPowershell = "powershell"
)
func NewLocalDeployer(option *DeployerOption) (Deployer, error) {
return &LocalDeployer{
option: option,
@@ -38,74 +50,114 @@ func (d *LocalDeployer) Deploy(ctx context.Context) error {
return err
}
preCommand := getDeployString(d.option.DeployConfig, "preCommand")
// 执行前置命令
preCommand := d.option.DeployConfig.GetConfigAsString("preCommand")
if preCommand != "" {
if err := execCmd(preCommand); err != nil {
return fmt.Errorf("执行前置命令失败: %w", err)
stdout, stderr, err := d.execCommand(preCommand)
if err != nil {
return fmt.Errorf("failed to run pre-command: %w, stdout: %s, stderr: %s", err, stdout, stderr)
}
d.infos = append(d.infos, toStr("执行前置命令成功", stdout))
}
// 复制证书文件
if err := copyFile(getDeployString(d.option.DeployConfig, "certPath"), d.option.Certificate.Certificate); err != nil {
return fmt.Errorf("复制证书失败: %w", err)
}
// 写入证书和私钥文件
switch d.option.DeployConfig.GetConfigOrDefaultAsString("format", certFormatPEM) {
case certFormatPEM:
if err := fs.WriteFileString(d.option.DeployConfig.GetConfigAsString("certPath"), d.option.Certificate.Certificate); err != nil {
return fmt.Errorf("failed to save certificate file: %w", err)
}
// 复制私钥文件
if err := copyFile(getDeployString(d.option.DeployConfig, "keyPath"), d.option.Certificate.PrivateKey); err != nil {
return fmt.Errorf("复制私钥失败: %w", err)
d.infos = append(d.infos, toStr("保存证书成功", nil))
if err := fs.WriteFileString(d.option.DeployConfig.GetConfigAsString("keyPath"), d.option.Certificate.PrivateKey); err != nil {
return fmt.Errorf("failed to save private key file: %w", err)
}
d.infos = append(d.infos, toStr("保存私钥成功", nil))
case certFormatPFX:
pfxData, err := convertPEMToPFX(
d.option.Certificate.Certificate,
d.option.Certificate.PrivateKey,
d.option.DeployConfig.GetConfigAsString("pfxPassword"),
)
if err != nil {
return fmt.Errorf("failed to convert pem to pfx %w", err)
}
if err := fs.WriteFile(d.option.DeployConfig.GetConfigAsString("certPath"), pfxData); err != nil {
return fmt.Errorf("failed to save certificate file: %w", err)
}
d.infos = append(d.infos, toStr("保存证书成功", nil))
case certFormatJKS:
jksData, err := convertPEMToJKS(
d.option.Certificate.Certificate,
d.option.Certificate.PrivateKey,
d.option.DeployConfig.GetConfigAsString("jksAlias"),
d.option.DeployConfig.GetConfigAsString("jksKeypass"),
d.option.DeployConfig.GetConfigAsString("jksStorepass"),
)
if err != nil {
return fmt.Errorf("failed to convert pem to pfx %w", err)
}
if err := fs.WriteFile(d.option.DeployConfig.GetConfigAsString("certPath"), jksData); err != nil {
return fmt.Errorf("failed to save certificate file: %w", err)
}
d.infos = append(d.infos, toStr("保存证书成功", nil))
}
// 执行命令
if err := execCmd(getDeployString(d.option.DeployConfig, "command")); err != nil {
return fmt.Errorf("执行命令失败: %w", err)
command := d.option.DeployConfig.GetConfigAsString("command")
if command != "" {
stdout, stderr, err := d.execCommand(command)
if err != nil {
return fmt.Errorf("failed to run command: %w, stdout: %s, stderr: %s", err, stdout, stderr)
}
d.infos = append(d.infos, toStr("执行命令成功", stdout))
}
return nil
}
func execCmd(command string) error {
// 执行命令
func (d *LocalDeployer) execCommand(command string) (string, string, error) {
var cmd *exec.Cmd
if runtime.GOOS == "windows" {
cmd = exec.Command("cmd", "/C", command)
} else {
switch d.option.DeployConfig.GetConfigAsString("shell") {
case shellEnvSh:
cmd = exec.Command("sh", "-c", command)
case shellEnvCmd:
cmd = exec.Command("cmd", "/C", command)
case shellEnvPowershell:
cmd = exec.Command("powershell", "-Command", command)
case "":
if runtime.GOOS == "windows" {
cmd = exec.Command("cmd", "/C", command)
} else {
cmd = exec.Command("sh", "-c", command)
}
default:
return "", "", fmt.Errorf("unsupported shell")
}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
var stdoutBuf bytes.Buffer
cmd.Stdout = &stdoutBuf
var stderrBuf bytes.Buffer
cmd.Stderr = &stderrBuf
err := cmd.Run()
if err != nil {
return fmt.Errorf("执行命令失败: %w", err)
return "", "", fmt.Errorf("failed to execute script: %w", err)
}
return nil
}
func copyFile(path string, content string) error {
dir := filepath.Dir(path)
// 如果目录不存在,创建目录
err := os.MkdirAll(dir, os.ModePerm)
if err != nil {
return fmt.Errorf("创建目录失败: %w", err)
}
// 创建或打开文件
file, err := os.Create(path)
if err != nil {
return fmt.Errorf("创建文件失败: %w", err)
}
defer file.Close()
// 写入内容到文件
_, err = file.Write([]byte(content))
if err != nil {
return fmt.Errorf("写入文件失败: %w", err)
}
return nil
return stdoutBuf.String(), stderrBuf.String(), err
}

View File

@@ -59,7 +59,7 @@ func (d *QiniuCDNDeployer) Deploy(ctx context.Context) error {
if domainInfo.Https != nil && domainInfo.Https.CertID != "" {
// 启用了 https
// 修改域名证书
err = d.modifyDomainCert(certId)
err = d.modifyDomainCert(certId, domainInfo.Https.ForceHttps, domainInfo.Https.Http2Enable)
if err != nil {
return fmt.Errorf("modifyDomainCert failed: %w", err)
}
@@ -166,14 +166,14 @@ type qiniuModifyDomainCertReq struct {
Http2Enable bool `json:"http2Enable"`
}
func (d *QiniuCDNDeployer) modifyDomainCert(certId string) error {
func (d *QiniuCDNDeployer) modifyDomainCert(certId string, forceHttps, http2Enable bool) error {
domain := d.option.DeployConfig.GetDomain()
path := fmt.Sprintf("/domain/%s/httpsconf", domain)
body := &qiniuModifyDomainCertReq{
CertID: certId,
ForceHttps: true,
Http2Enable: true,
ForceHttps: forceHttps,
Http2Enable: http2Enable,
}
bodyBytes, err := json.Marshal(body)

View File

@@ -24,7 +24,6 @@ func Test_qiuniu_uploadCert(t *testing.T) {
option: &DeployerOption{
DomainId: "1",
Domain: "example.com",
Product: "test",
Access: `{"bucket":"test","accessKey":"","secretKey":""}`,
Certificate: applicant.Certificate{
Certificate: "",
@@ -70,7 +69,6 @@ func Test_qiuniu_modifyDomainCert(t *testing.T) {
option: &DeployerOption{
DomainId: "1",
Domain: "jt1.ikit.fun",
Product: "test",
Access: `{"bucket":"test","accessKey":"","secretKey":""}`,
},
},
@@ -79,7 +77,7 @@ func Test_qiuniu_modifyDomainCert(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
q, _ := NewQiniuCDNDeployer(tt.fields.option)
if err := q.modifyDomainCert(tt.args.certId); (err != nil) != tt.wantErr {
if err := q.modifyDomainCert(tt.args.certId, true, true); (err != nil) != tt.wantErr {
t.Errorf("qiuniu.modifyDomainCert() error = %v, wantErr %v", err, tt.wantErr)
}
})

View File

@@ -6,12 +6,13 @@ import (
"encoding/json"
"fmt"
"os"
xpath "path"
"path/filepath"
"github.com/pkg/sftp"
sshPkg "golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh"
"github.com/usual2970/certimate/internal/domain"
"github.com/usual2970/certimate/internal/pkg/utils/fs"
)
type SSHDeployer struct {
@@ -41,49 +42,120 @@ func (d *SSHDeployer) Deploy(ctx context.Context) error {
}
// 连接
client, err := d.createClient(access)
client, err := d.createSshClient(access)
if err != nil {
return err
}
defer client.Close()
d.infos = append(d.infos, toStr("ssh连接成功", nil))
d.infos = append(d.infos, toStr("SSH 连接成功", nil))
// 执行前置命令
preCommand := getDeployString(d.option.DeployConfig, "preCommand")
preCommand := d.option.DeployConfig.GetConfigAsString("preCommand")
if preCommand != "" {
stdout, stderr, err := d.sshExecCommand(client, preCommand)
if err != nil {
return fmt.Errorf("failed to run pre-command: %w, stdout: %s, stderr: %s", err, stdout, stderr)
}
d.infos = append(d.infos, toStr("SSH 执行前置命令成功", stdout))
}
// 上传证书
if err := d.upload(client, d.option.Certificate.Certificate, getDeployString(d.option.DeployConfig, "certPath")); err != nil {
return fmt.Errorf("failed to upload certificate: %w", err)
// 上传证书和私钥文件
switch d.option.DeployConfig.GetConfigOrDefaultAsString("format", certFormatPEM) {
case certFormatPEM:
if err := d.writeSftpFileString(client, d.option.DeployConfig.GetConfigAsString("certPath"), d.option.Certificate.Certificate); err != nil {
return fmt.Errorf("failed to upload certificate file: %w", err)
}
d.infos = append(d.infos, toStr("SSH 上传证书成功", nil))
if err := d.writeSftpFileString(client, d.option.DeployConfig.GetConfigAsString("keyPath"), d.option.Certificate.PrivateKey); err != nil {
return fmt.Errorf("failed to upload private key file: %w", err)
}
d.infos = append(d.infos, toStr("SSH 上传私钥成功", nil))
case certFormatPFX:
pfxData, err := convertPEMToPFX(
d.option.Certificate.Certificate,
d.option.Certificate.PrivateKey,
d.option.DeployConfig.GetConfigAsString("pfxPassword"),
)
if err != nil {
return fmt.Errorf("failed to convert pem to pfx %w", err)
}
if err := d.writeSftpFile(client, d.option.DeployConfig.GetConfigAsString("certPath"), pfxData); err != nil {
return fmt.Errorf("failed to upload certificate file: %w", err)
}
d.infos = append(d.infos, toStr("SSH 上传证书成功", nil))
case certFormatJKS:
jksData, err := convertPEMToJKS(
d.option.Certificate.Certificate,
d.option.Certificate.PrivateKey,
d.option.DeployConfig.GetConfigAsString("jksAlias"),
d.option.DeployConfig.GetConfigAsString("jksKeypass"),
d.option.DeployConfig.GetConfigAsString("jksStorepass"),
)
if err != nil {
return fmt.Errorf("failed to convert pem to pfx %w", err)
}
if err := fs.WriteFile(d.option.DeployConfig.GetConfigAsString("certPath"), jksData); err != nil {
return fmt.Errorf("failed to save certificate file: %w", err)
}
d.infos = append(d.infos, toStr("保存证书成功", nil))
}
d.infos = append(d.infos, toStr("ssh上传证书成功", nil))
// 上传私钥
if err := d.upload(client, d.option.Certificate.PrivateKey, getDeployString(d.option.DeployConfig, "keyPath")); err != nil {
return fmt.Errorf("failed to upload private key: %w", err)
}
d.infos = append(d.infos, toStr("ssh上传私钥成功", nil))
// 执行命令
stdout, stderr, err := d.sshExecCommand(client, getDeployString(d.option.DeployConfig, "command"))
if err != nil {
return fmt.Errorf("failed to run command: %w, stdout: %s, stderr: %s", err, stdout, stderr)
}
command := d.option.DeployConfig.GetConfigAsString("command")
if command != "" {
stdout, stderr, err := d.sshExecCommand(client, command)
if err != nil {
return fmt.Errorf("failed to run command: %w, stdout: %s, stderr: %s", err, stdout, stderr)
}
d.infos = append(d.infos, toStr("ssh执行命令成功", stdout))
d.infos = append(d.infos, toStr("SSH 执行命令成功", stdout))
}
return nil
}
func (d *SSHDeployer) sshExecCommand(client *sshPkg.Client, command string) (string, string, error) {
func (d *SSHDeployer) createSshClient(access *domain.SSHAccess) (*ssh.Client, error) {
var authMethod ssh.AuthMethod
if access.Key != "" {
var signer ssh.Signer
var err error
if access.KeyPassphrase != "" {
signer, err = ssh.ParsePrivateKeyWithPassphrase([]byte(access.Key), []byte(access.KeyPassphrase))
} else {
signer, err = ssh.ParsePrivateKey([]byte(access.Key))
}
if err != nil {
return nil, err
}
authMethod = ssh.PublicKeys(signer)
} else {
authMethod = ssh.Password(access.Password)
}
return ssh.Dial("tcp", fmt.Sprintf("%s:%s", access.Host, access.Port), &ssh.ClientConfig{
User: access.Username,
Auth: []ssh.AuthMethod{
authMethod,
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
})
}
func (d *SSHDeployer) sshExecCommand(client *ssh.Client, command string) (string, string, error) {
session, err := client.NewSession()
if err != nil {
return "", "", fmt.Errorf("failed to create ssh session: %w", err)
@@ -98,14 +170,18 @@ func (d *SSHDeployer) sshExecCommand(client *sshPkg.Client, command string) (str
return stdoutBuf.String(), stderrBuf.String(), err
}
func (d *SSHDeployer) upload(client *sshPkg.Client, content, path string) error {
func (d *SSHDeployer) writeSftpFileString(client *ssh.Client, path string, content string) error {
return d.writeSftpFile(client, path, []byte(content))
}
func (d *SSHDeployer) writeSftpFile(client *ssh.Client, path string, data []byte) error {
sftpCli, err := sftp.NewClient(client)
if err != nil {
return fmt.Errorf("failed to create sftp client: %w", err)
}
defer sftpCli.Close()
if err := sftpCli.MkdirAll(xpath.Dir(path)); err != nil {
if err := sftpCli.MkdirAll(filepath.Dir(path)); err != nil {
return fmt.Errorf("failed to create remote directory: %w", err)
}
@@ -115,40 +191,10 @@ func (d *SSHDeployer) upload(client *sshPkg.Client, content, path string) error
}
defer file.Close()
_, err = file.Write([]byte(content))
_, err = file.Write(data)
if err != nil {
return fmt.Errorf("failed to write to remote file: %w", err)
}
return nil
}
func (d *SSHDeployer) createClient(access *domain.SSHAccess) (*sshPkg.Client, error) {
var authMethod sshPkg.AuthMethod
if access.Key != "" {
var signer sshPkg.Signer
var err error
if access.KeyPassphrase != "" {
signer, err = sshPkg.ParsePrivateKeyWithPassphrase([]byte(access.Key), []byte(access.KeyPassphrase))
} else {
signer, err = sshPkg.ParsePrivateKey([]byte(access.Key))
}
if err != nil {
return nil, err
}
authMethod = sshPkg.PublicKeys(signer)
} else {
authMethod = sshPkg.Password(access.Password)
}
return sshPkg.Dial("tcp", fmt.Sprintf("%s:%s", access.Host, access.Port), &sshPkg.ClientConfig{
User: access.Username,
Auth: []sshPkg.AuthMethod{
authMethod,
},
HostKeyCallback: sshPkg.InsecureIgnoreHostKey(),
})
}

View File

@@ -2,11 +2,12 @@ package deployer
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"golang.org/x/exp/slices"
cdn "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cdn/v20180606"
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common"
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile"
@@ -100,16 +101,23 @@ func (d *TencentCDNDeployer) deploy(certId string) error {
// 如果是泛域名就从cdn列表下获取SSL证书中的可用域名
domain := getDeployString(d.option.DeployConfig, "domain")
if strings.Contains(domain, "*") {
list, errGetList := d.getDomainList()
list, errGetList := d.getDomainList(certId)
if errGetList != nil {
return fmt.Errorf("failed to get certificate domain list: %w", errGetList)
}
if list == nil || len(list) == 0 {
return fmt.Errorf("failed to get certificate domain list: empty list.")
if len(list) == 0 {
d.infos = append(d.infos, "没有需要部署的实例")
return nil
}
request.InstanceIdList = common.StringPtrs(list)
} else { // 否则直接使用传入的域名
request.InstanceIdList = common.StringPtrs([]string{domain})
deployed, _ := d.isDomainDeployed(certId, domain)
if deployed {
d.infos = append(d.infos, "域名已部署")
return nil
} else {
request.InstanceIdList = common.StringPtrs([]string{domain})
}
}
// 返回的resp是一个DeployCertificateInstanceResponse的实例与请求对象对应
@@ -121,23 +129,61 @@ func (d *TencentCDNDeployer) deploy(certId string) error {
return nil
}
func (d *TencentCDNDeployer) getDomainList() ([]string, error) {
func (d *TencentCDNDeployer) getDomainList(certId string) ([]string, error) {
cpf := profile.NewClientProfile()
cpf.HttpProfile.Endpoint = "cdn.tencentcloudapi.com"
client, _ := cdn.NewClient(d.credential, "", cpf)
request := cdn.NewDescribeCertDomainsRequest()
cert := base64.StdEncoding.EncodeToString([]byte(d.option.Certificate.Certificate))
request.Cert = &cert
request.CertId = common.StringPtr(certId)
response, err := client.DescribeCertDomains(request)
if err != nil {
return nil, fmt.Errorf("failed to get domain list: %w", err)
}
deployedDomains, err := d.getDeployedDomainList(certId)
if err != nil {
return nil, fmt.Errorf("failed to get deployed domain list: %w", err)
}
domains := make([]string, 0)
for _, domain := range response.Response.Domains {
domainStr := *domain
if !slices.Contains(deployedDomains, domainStr) {
domains = append(domains, domainStr)
}
}
return domains, nil
}
func (d *TencentCDNDeployer) isDomainDeployed(certId, domain string) (bool, error) {
deployedDomains, err := d.getDeployedDomainList(certId)
if err != nil {
return false, err
}
return slices.Contains(deployedDomains, domain), nil
}
func (d *TencentCDNDeployer) getDeployedDomainList(certId string) ([]string, error) {
cpf := profile.NewClientProfile()
cpf.HttpProfile.Endpoint = "ssl.tencentcloudapi.com"
client, _ := ssl.NewClient(d.credential, "", cpf)
request := ssl.NewDescribeDeployedResourcesRequest()
request.CertificateIds = common.StringPtrs([]string{certId})
request.ResourceType = common.StringPtr("cdn")
response, err := client.DescribeDeployedResources(request)
if err != nil {
return nil, fmt.Errorf("failed to get deployed domain list: %w", err)
}
domains := make([]string, 0)
for _, domain := range response.Response.DeployedResources[0].Resources {
domains = append(domains, *domain)
}

View File

@@ -0,0 +1,146 @@
package deployer
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
cdn "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cdn/v20180606"
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common"
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile"
ssl "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ssl/v20191205"
"github.com/usual2970/certimate/internal/domain"
"github.com/usual2970/certimate/internal/utils/rand"
)
type TencentECDNDeployer struct {
option *DeployerOption
credential *common.Credential
infos []string
}
func NewTencentECDNDeployer(option *DeployerOption) (Deployer, error) {
access := &domain.TencentAccess{}
if err := json.Unmarshal([]byte(option.Access), access); err != nil {
return nil, fmt.Errorf("failed to unmarshal tencent access: %w", err)
}
credential := common.NewCredential(
access.SecretId,
access.SecretKey,
)
return &TencentECDNDeployer{
option: option,
credential: credential,
infos: make([]string, 0),
}, nil
}
func (d *TencentECDNDeployer) GetID() string {
return fmt.Sprintf("%s-%s", d.option.AccessRecord.GetString("name"), d.option.AccessRecord.Id)
}
func (d *TencentECDNDeployer) GetInfo() []string {
return d.infos
}
func (d *TencentECDNDeployer) Deploy(ctx context.Context) error {
// 上传证书
certId, err := d.uploadCert()
if err != nil {
return fmt.Errorf("failed to upload certificate: %w", err)
}
d.infos = append(d.infos, toStr("上传证书", certId))
if err := d.deploy(certId); err != nil {
return fmt.Errorf("failed to deploy: %w", err)
}
return nil
}
func (d *TencentECDNDeployer) uploadCert() (string, error) {
cpf := profile.NewClientProfile()
cpf.HttpProfile.Endpoint = "ssl.tencentcloudapi.com"
client, _ := ssl.NewClient(d.credential, "", cpf)
request := ssl.NewUploadCertificateRequest()
request.CertificatePublicKey = common.StringPtr(d.option.Certificate.Certificate)
request.CertificatePrivateKey = common.StringPtr(d.option.Certificate.PrivateKey)
request.Alias = common.StringPtr(d.option.Domain + "_" + rand.RandStr(6))
request.Repeatable = common.BoolPtr(false)
response, err := client.UploadCertificate(request)
if err != nil {
return "", fmt.Errorf("failed to upload certificate: %w", err)
}
return *response.Response.CertificateId, nil
}
func (d *TencentECDNDeployer) deploy(certId string) error {
cpf := profile.NewClientProfile()
cpf.HttpProfile.Endpoint = "ssl.tencentcloudapi.com"
// 实例化要请求产品的client对象,clientProfile是可选的
client, _ := ssl.NewClient(d.credential, "", cpf)
// 实例化一个请求对象,每个接口都会对应一个request对象
request := ssl.NewDeployCertificateInstanceRequest()
request.CertificateId = common.StringPtr(certId)
request.ResourceType = common.StringPtr("ecdn")
request.Status = common.Int64Ptr(1)
// 如果是泛域名就从cdn列表下获取SSL证书中的可用域名
domain := getDeployString(d.option.DeployConfig, "domain")
if strings.Contains(domain, "*") {
list, errGetList := d.getDomainList()
if errGetList != nil {
return fmt.Errorf("failed to get certificate domain list: %w", errGetList)
}
if list == nil || len(list) == 0 {
return fmt.Errorf("failed to get certificate domain list: empty list.")
}
request.InstanceIdList = common.StringPtrs(list)
} else { // 否则直接使用传入的域名
request.InstanceIdList = common.StringPtrs([]string{domain})
}
// 返回的resp是一个DeployCertificateInstanceResponse的实例与请求对象对应
resp, err := client.DeployCertificateInstance(request)
if err != nil {
return fmt.Errorf("failed to deploy certificate: %w", err)
}
d.infos = append(d.infos, toStr("部署证书", resp.Response))
return nil
}
func (d *TencentECDNDeployer) getDomainList() ([]string, error) {
cpf := profile.NewClientProfile()
cpf.HttpProfile.Endpoint = "cdn.tencentcloudapi.com"
client, _ := cdn.NewClient(d.credential, "", cpf)
request := cdn.NewDescribeCertDomainsRequest()
cert := base64.StdEncoding.EncodeToString([]byte(d.option.Certificate.Certificate))
request.Cert = &cert
request.Product = common.StringPtr("ecdn")
response, err := client.DescribeCertDomains(request)
if err != nil {
return nil, fmt.Errorf("failed to get domain list: %w", err)
}
domains := make([]string, 0)
for _, domain := range response.Response.Domains {
domains = append(domains, *domain)
}
return domains, nil
}

View File

@@ -0,0 +1,111 @@
package deployer
import (
"context"
"encoding/json"
"fmt"
"strings"
teo "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/teo/v20220901"
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common"
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile"
ssl "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ssl/v20191205"
"github.com/usual2970/certimate/internal/domain"
"github.com/usual2970/certimate/internal/utils/rand"
)
type TencentTEODeployer struct {
option *DeployerOption
credential *common.Credential
infos []string
}
func NewTencentTEODeployer(option *DeployerOption) (Deployer, error) {
access := &domain.TencentAccess{}
if err := json.Unmarshal([]byte(option.Access), access); err != nil {
return nil, fmt.Errorf("failed to unmarshal tencent access: %w", err)
}
credential := common.NewCredential(
access.SecretId,
access.SecretKey,
)
return &TencentTEODeployer{
option: option,
credential: credential,
infos: make([]string, 0),
}, nil
}
func (d *TencentTEODeployer) GetID() string {
return fmt.Sprintf("%s-%s", d.option.AccessRecord.GetString("name"), d.option.AccessRecord.Id)
}
func (d *TencentTEODeployer) GetInfo() []string {
return d.infos
}
func (d *TencentTEODeployer) Deploy(ctx context.Context) error {
// 上传证书
certId, err := d.uploadCert()
if err != nil {
return fmt.Errorf("failed to upload certificate: %w", err)
}
d.infos = append(d.infos, toStr("上传证书", certId))
if err := d.deploy(certId); err != nil {
return fmt.Errorf("failed to deploy: %w", err)
}
return nil
}
func (d *TencentTEODeployer) uploadCert() (string, error) {
cpf := profile.NewClientProfile()
cpf.HttpProfile.Endpoint = "ssl.tencentcloudapi.com"
client, _ := ssl.NewClient(d.credential, "", cpf)
request := ssl.NewUploadCertificateRequest()
request.CertificatePublicKey = common.StringPtr(d.option.Certificate.Certificate)
request.CertificatePrivateKey = common.StringPtr(d.option.Certificate.PrivateKey)
request.Alias = common.StringPtr(d.option.Domain + "_" + rand.RandStr(6))
request.Repeatable = common.BoolPtr(false)
response, err := client.UploadCertificate(request)
if err != nil {
return "", fmt.Errorf("failed to upload certificate: %w", err)
}
return *response.Response.CertificateId, nil
}
func (d *TencentTEODeployer) deploy(certId string) error {
cpf := profile.NewClientProfile()
cpf.HttpProfile.Endpoint = "teo.tencentcloudapi.com"
// 实例化要请求产品的client对象,clientProfile是可选的
client, _ := teo.NewClient(d.credential, "", cpf)
// 实例化一个请求对象,每个接口都会对应一个request对象
request := teo.NewModifyHostsCertificateRequest()
request.ZoneId = common.StringPtr(getDeployString(d.option.DeployConfig, "zoneId"))
request.Mode = common.StringPtr("sslcert")
request.ServerCertInfo = []*teo.ServerCertInfo{{
CertId: common.StringPtr(certId),
}}
domains := strings.Split(strings.ReplaceAll(d.option.Domain, "\r\n", "\n"),"\n")
request.Hosts = common.StringPtrs(domains)
// 返回的resp是一个DeployCertificateInstanceResponse的实例与请求对象对应
resp, err := client.ModifyHostsCertificate(request)
if err != nil {
return fmt.Errorf("failed to deploy certificate: %w", err)
}
d.infos = append(d.infos, toStr("部署证书", resp.Response))
return nil
}

View File

@@ -18,7 +18,6 @@ type DeployConfig struct {
Config map[string]any `json:"config"`
}
// 以字符串形式获取配置项。
//
// 入参:
@@ -52,6 +51,39 @@ func (dc *DeployConfig) GetConfigOrDefaultAsString(key string, defaultValue stri
return defaultValue
}
// 以 32 位整数形式获取配置项。
//
// 入参:
// - key: 配置项的键。
//
// 出参:
// - 配置项的值。如果配置项不存在或者类型不是 32 位整数,则返回 0。
func (dc *DeployConfig) GetConfigAsInt32(key string) int32 {
return dc.GetConfigOrDefaultAsInt32(key, 0)
}
// 以 32 位整数形式获取配置项。
//
// 入参:
// - key: 配置项的键。
// - defaultValue: 默认值。
//
// 出参:
// - 配置项的值。如果配置项不存在或者类型不是 32 位整数,则返回默认值。
func (dc *DeployConfig) GetConfigOrDefaultAsInt32(key string, defaultValue int32) int32 {
if dc.Config == nil {
return defaultValue
}
if value, ok := dc.Config[key]; ok {
if result, ok := value.(int32); ok {
return result
}
}
return defaultValue
}
// 以布尔形式获取配置项。
//
// 入参:

View File

@@ -5,6 +5,8 @@ const (
NotifyChannelWebhook = "webhook"
NotifyChannelTelegram = "telegram"
NotifyChannelLark = "lark"
NotifyChannelServerChan = "serverchan"
NotifyChannelMail = "mail"
)
type NotifyTestPushReq struct {

58
internal/notify/mail.go Normal file
View File

@@ -0,0 +1,58 @@
package notify
import (
"context"
"net/smtp"
)
type Mail struct {
senderAddress string
smtpHostAddr string
smtpHostPort string
smtpAuth smtp.Auth
receiverAddresses string
}
func NewMail(senderAddress, receiverAddresses, smtpHostAddr, smtpHostPort string) *Mail {
if(smtpHostPort == "") {
smtpHostPort = "25"
}
return &Mail{
senderAddress: senderAddress,
smtpHostAddr: smtpHostAddr,
smtpHostPort: smtpHostPort,
receiverAddresses: receiverAddresses,
}
}
func (m *Mail) SetAuth(username, password string) {
m.smtpAuth = smtp.PlainAuth("", username, password, m.smtpHostAddr)
}
func (m *Mail) Send(ctx context.Context, subject, message string) error {
// 构建邮件
from := m.senderAddress
to := []string{m.receiverAddresses}
msg := []byte(
"From: " + from + "\r\n" +
"To: " + m.receiverAddresses + "\r\n" +
"Subject: " + subject + "\r\n" +
"\r\n" +
message + "\r\n")
var smtpAddress string
// 组装邮箱服务器地址
if(m.smtpHostPort == "25"){
smtpAddress = m.smtpHostAddr
}else{
smtpAddress = m.smtpHostAddr + ":" + m.smtpHostPort
}
err := smtp.SendMail(smtpAddress, m.smtpAuth, from, to, msg)
if err != nil {
return err
}
return nil
}

View File

@@ -5,6 +5,8 @@ import (
"fmt"
"strconv"
stdhttp "net/http"
"github.com/usual2970/certimate/internal/domain"
"github.com/usual2970/certimate/internal/utils/app"
@@ -102,6 +104,10 @@ func getNotifier(channel string, conf map[string]any) (notifyPackage.Notifier, e
return getLarkNotifier(conf), nil
case domain.NotifyChannelWebhook:
return getWebhookNotifier(conf), nil
case domain.NotifyChannelServerChan:
return getServerChanNotifier(conf), nil
case domain.NotifyChannelMail:
return getMailNotifier(conf), nil
}
return nil, fmt.Errorf("notifier not found")
@@ -132,6 +138,25 @@ func getTelegramNotifier(conf map[string]any) notifyPackage.Notifier {
return rs
}
func getServerChanNotifier(conf map[string]any) notifyPackage.Notifier {
rs := http.New()
rs.AddReceivers(&http.Webhook{
URL: getString(conf, "url"),
Header: stdhttp.Header{},
ContentType: "application/json",
Method: stdhttp.MethodPost,
BuildPayload: func(subject, message string) (payload any) {
return map[string]string{
"text": subject,
"desp": message,
}
},
})
return rs
}
func getDingTalkNotifier(conf map[string]any) notifyPackage.Notifier {
return dingding.New(&dingding.Config{
Token: getString(conf, "accessToken"),
@@ -143,6 +168,14 @@ func getLarkNotifier(conf map[string]any) notifyPackage.Notifier {
return lark.NewWebhookService(getString(conf, "webhookUrl"))
}
func getMailNotifier(conf map[string]any) notifyPackage.Notifier {
rs := NewMail(getString(conf, "senderAddress"),getString(conf,"receiverAddress"), getString(conf, "smtpHostAddr"), getString(conf, "smtpHostPort"))
rs.SetAuth(getString(conf, "username"), getString(conf, "password"))
return rs
}
func getString(conf map[string]any, key string) string {
if _, ok := conf[key]; !ok {
return ""

View File

@@ -9,13 +9,13 @@ type Uploader interface {
// 上传证书。
//
// 入参:
// - ctx
// - certPem证书 PEM 内容
// - privkeyPem私钥 PEM 内容
// - ctx上下文。
// - certPem证书 PEM 内容
// - privkeyPem私钥 PEM 内容
//
// 出参:
// - res
// - err
// - res上传结果。
// - err: 错误。
Upload(ctx context.Context, certPem string, privkeyPem string) (res *UploadResult, err error)
}

View File

@@ -15,9 +15,9 @@ import (
)
type AliyunCASUploaderConfig struct {
Region string `json:"region"`
AccessKeyId string `json:"accessKeyId"`
AccessKeySecret string `json:"accessKeySecret"`
Region string `json:"region"`
}
type AliyunCASUploader struct {
@@ -26,8 +26,12 @@ type AliyunCASUploader struct {
sdkRuntime *util.RuntimeOptions
}
func NewAliyunCASUploader(config *AliyunCASUploaderConfig) (*AliyunCASUploader, error) {
client, err := (&AliyunCASUploader{config: config}).createSdkClient()
func NewAliyunCASUploader(config *AliyunCASUploaderConfig) (Uploader, error) {
client, err := (&AliyunCASUploader{}).createSdkClient(
config.AccessKeyId,
config.AccessKeySecret,
config.Region,
)
if err != nil {
return nil, fmt.Errorf("failed to create sdk client: %w", err)
}
@@ -77,12 +81,12 @@ func (u *AliyunCASUploader) Upload(ctx context.Context, certPem string, privkeyP
if *getUserCertificateDetailResp.Body.Cert == certPem {
isSameCert = true
} else {
cert, err := x509.ParseCertificateFromPEM(*getUserCertificateDetailResp.Body.Cert)
oldCertX509, err := x509.ParseCertificateFromPEM(*getUserCertificateDetailResp.Body.Cert)
if err != nil {
continue
}
isSameCert = x509.EqualCertificate(certX509, cert)
isSameCert = x509.EqualCertificate(certX509, oldCertX509)
}
// 如果已存在相同证书,直接返回已有的证书信息
@@ -98,11 +102,11 @@ func (u *AliyunCASUploader) Upload(ctx context.Context, certPem string, privkeyP
if listUserCertificateOrderResp.Body.CertificateOrderList == nil || len(listUserCertificateOrderResp.Body.CertificateOrderList) < int(listUserCertificateOrderLimit) {
break
}
listUserCertificateOrderPage += 1
if listUserCertificateOrderPage > 99 { // 避免死循环
break
} else {
listUserCertificateOrderPage += 1
if listUserCertificateOrderPage > 99 { // 避免死循环
break
}
}
}
@@ -129,10 +133,7 @@ func (u *AliyunCASUploader) Upload(ctx context.Context, certPem string, privkeyP
}, nil
}
func (u *AliyunCASUploader) createSdkClient() (*cas20200407.Client, error) {
region := u.config.Region
accessKeyId := u.config.AccessKeyId
accessKeySecret := u.config.AccessKeySecret
func (u *AliyunCASUploader) createSdkClient(accessKeyId, accessKeySecret, region string) (*cas20200407.Client, error) {
if region == "" {
region = "cn-hangzhou" // CAS 服务默认区域:华东一杭州
}
@@ -146,10 +147,6 @@ func (u *AliyunCASUploader) createSdkClient() (*cas20200407.Client, error) {
switch region {
case "cn-hangzhou":
endpoint = "cas.aliyuncs.com"
case "ap-southeast-1":
endpoint = "cas.ap-southeast-1.aliyuncs.com"
case "eu-central-1":
endpoint = "cas.eu-central-1.aliyuncs.com"
default:
endpoint = fmt.Sprintf("cas.%s.aliyuncs.com", region)
}

View File

@@ -0,0 +1,134 @@
package uploader
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"time"
openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client"
slb20140515 "github.com/alibabacloud-go/slb-20140515/v4/client"
util "github.com/alibabacloud-go/tea-utils/v2/service"
"github.com/alibabacloud-go/tea/tea"
"github.com/usual2970/certimate/internal/pkg/utils/x509"
)
type AliyunSLBUploaderConfig struct {
AccessKeyId string `json:"accessKeyId"`
AccessKeySecret string `json:"accessKeySecret"`
Region string `json:"region"`
}
type AliyunSLBUploader struct {
config *AliyunSLBUploaderConfig
sdkClient *slb20140515.Client
sdkRuntime *util.RuntimeOptions
}
func NewAliyunSLBUploader(config *AliyunSLBUploaderConfig) (Uploader, error) {
client, err := (&AliyunSLBUploader{}).createSdkClient(
config.AccessKeyId,
config.AccessKeySecret,
config.Region,
)
if err != nil {
return nil, fmt.Errorf("failed to create sdk client: %w", err)
}
return &AliyunSLBUploader{
config: config,
sdkClient: client,
sdkRuntime: &util.RuntimeOptions{},
}, nil
}
func (u *AliyunSLBUploader) Upload(ctx context.Context, certPem string, privkeyPem string) (res *UploadResult, err error) {
// 解析证书内容
certX509, err := x509.ParseCertificateFromPEM(certPem)
if err != nil {
return nil, err
}
// 查询证书列表,避免重复上传
// REF: https://help.aliyun.com/zh/slb/classic-load-balancer/developer-reference/api-slb-2014-05-15-describeservercertificates
describeServerCertificatesReq := &slb20140515.DescribeServerCertificatesRequest{
RegionId: tea.String(u.config.Region),
}
describeServerCertificatesResp, err := u.sdkClient.DescribeServerCertificatesWithOptions(describeServerCertificatesReq, u.sdkRuntime)
if err != nil {
return nil, fmt.Errorf("failed to execute sdk request 'slb.DescribeServerCertificates': %w", err)
}
if describeServerCertificatesResp.Body.ServerCertificates != nil && describeServerCertificatesResp.Body.ServerCertificates.ServerCertificate != nil {
fingerprint := sha256.Sum256(certX509.Raw)
fingerprintHex := hex.EncodeToString(fingerprint[:])
for _, certDetail := range describeServerCertificatesResp.Body.ServerCertificates.ServerCertificate {
isSameCert := *certDetail.IsAliCloudCertificate == 0 &&
strings.EqualFold(fingerprintHex, strings.ReplaceAll(*certDetail.Fingerprint, ":", "")) &&
strings.EqualFold(certX509.Subject.CommonName, *certDetail.CommonName)
// 如果已存在相同证书,直接返回已有的证书信息
if isSameCert {
return &UploadResult{
CertId: *certDetail.ServerCertificateId,
CertName: *certDetail.ServerCertificateName,
}, nil
}
}
}
// 生成新证书名(需符合阿里云命名规则)
var certId, certName string
certName = fmt.Sprintf("certimate_%d", time.Now().UnixMilli())
// 上传新证书
// REF: https://help.aliyun.com/zh/slb/classic-load-balancer/developer-reference/api-slb-2014-05-15-uploadservercertificate
uploadServerCertificateReq := &slb20140515.UploadServerCertificateRequest{
RegionId: tea.String(u.config.Region),
ServerCertificateName: tea.String(certName),
ServerCertificate: tea.String(certPem),
PrivateKey: tea.String(privkeyPem),
}
uploadServerCertificateResp, err := u.sdkClient.UploadServerCertificateWithOptions(uploadServerCertificateReq, u.sdkRuntime)
if err != nil {
return nil, fmt.Errorf("failed to execute sdk request 'slb.UploadServerCertificate': %w", err)
}
certId = *uploadServerCertificateResp.Body.ServerCertificateId
return &UploadResult{
CertId: certId,
CertName: certName,
}, nil
}
func (u *AliyunSLBUploader) createSdkClient(accessKeyId, accessKeySecret, region string) (*slb20140515.Client, error) {
if region == "" {
region = "cn-hangzhou" // SLB 服务默认区域:华东一杭州
}
aConfig := &openapi.Config{
AccessKeyId: tea.String(accessKeyId),
AccessKeySecret: tea.String(accessKeySecret),
}
var endpoint string
switch region {
case "cn-hangzhou":
case "cn-hangzhou-finance":
case "cn-shanghai-finance-1":
case "cn-shenzhen-finance-1":
endpoint = "slb.aliyuncs.com"
default:
endpoint = fmt.Sprintf("slb.%s.aliyuncs.com", region)
}
aConfig.Endpoint = tea.String(endpoint)
client, err := slb20140515.NewClient(aConfig)
if err != nil {
return nil, err
}
return client, nil
}

View File

@@ -6,19 +6,22 @@ import (
"time"
"github.com/huaweicloud/huaweicloud-sdk-go-v3/core/auth/basic"
"github.com/huaweicloud/huaweicloud-sdk-go-v3/core/auth/global"
hcElb "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/elb/v3"
hcElbModel "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/elb/v3/model"
hcElbRegion "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/elb/v3/region"
hcIam "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/iam/v3"
hcIamModel "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/iam/v3/model"
hcIamRegion "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/iam/v3/region"
"github.com/usual2970/certimate/internal/pkg/utils/cast"
"github.com/usual2970/certimate/internal/pkg/utils/x509"
)
type HuaweiCloudELBUploaderConfig struct {
Region string `json:"region"`
ProjectId string `json:"projectId"`
AccessKeyId string `json:"accessKeyId"`
SecretAccessKey string `json:"secretAccessKey"`
Region string `json:"region"`
}
type HuaweiCloudELBUploader struct {
@@ -26,8 +29,12 @@ type HuaweiCloudELBUploader struct {
sdkClient *hcElb.ElbClient
}
func NewHuaweiCloudELBUploader(config *HuaweiCloudELBUploaderConfig) (*HuaweiCloudELBUploader, error) {
client, err := (&HuaweiCloudELBUploader{config: config}).createSdkClient()
func NewHuaweiCloudELBUploader(config *HuaweiCloudELBUploaderConfig) (Uploader, error) {
client, err := (&HuaweiCloudELBUploader{}).createSdkClient(
config.AccessKeyId,
config.SecretAccessKey,
config.Region,
)
if err != nil {
return nil, fmt.Errorf("failed to create sdk client: %w", err)
}
@@ -87,13 +94,20 @@ func (u *HuaweiCloudELBUploader) Upload(ctx context.Context, certPem string, pri
if listCertificatesResp.Certificates == nil || len(*listCertificatesResp.Certificates) < int(listCertificatesLimit) {
break
} else {
listCertificatesMarker = listCertificatesResp.PageInfo.NextMarker
listCertificatesPage++
if listCertificatesPage >= 9 { // 避免死循环
break
}
}
}
listCertificatesMarker = listCertificatesResp.PageInfo.NextMarker
listCertificatesPage++
if listCertificatesPage >= 9 { // 避免死循环
break
}
// 获取项目 ID
// REF: https://support.huaweicloud.com/api-iam/iam_06_0001.html
projectId, err := u.getSdkProjectId(u.config.Region, u.config.AccessKeyId, u.config.SecretAccessKey)
if err != nil {
return nil, fmt.Errorf("failed to get SDK project id: %w", err)
}
// 生成新证书名(需符合华为云命名规则)
@@ -105,7 +119,7 @@ func (u *HuaweiCloudELBUploader) Upload(ctx context.Context, certPem string, pri
createCertificateReq := &hcElbModel.CreateCertificateRequest{
Body: &hcElbModel.CreateCertificateRequestBody{
Certificate: &hcElbModel.CreateCertificateOption{
ProjectId: cast.StringPtr(u.config.ProjectId),
ProjectId: cast.StringPtr(projectId),
Name: cast.StringPtr(certName),
Certificate: cast.StringPtr(certPem),
PrivateKey: cast.StringPtr(privkeyPem),
@@ -125,10 +139,7 @@ func (u *HuaweiCloudELBUploader) Upload(ctx context.Context, certPem string, pri
}, nil
}
func (u *HuaweiCloudELBUploader) createSdkClient() (*hcElb.ElbClient, error) {
region := u.config.Region
accessKeyId := u.config.AccessKeyId
secretAccessKey := u.config.SecretAccessKey
func (u *HuaweiCloudELBUploader) createSdkClient(accessKeyId, secretAccessKey, region string) (*hcElb.ElbClient, error) {
if region == "" {
region = "cn-north-4" // ELB 服务默认区域:华北四北京
}
@@ -157,3 +168,47 @@ func (u *HuaweiCloudELBUploader) createSdkClient() (*hcElb.ElbClient, error) {
client := hcElb.NewElbClient(hcClient)
return client, nil
}
func (u *HuaweiCloudELBUploader) getSdkProjectId(accessKeyId, secretAccessKey, region string) (string, error) {
if region == "" {
region = "cn-north-4" // IAM 服务默认区域:华北四北京
}
auth, err := global.NewCredentialsBuilder().
WithAk(accessKeyId).
WithSk(secretAccessKey).
SafeBuild()
if err != nil {
return "", err
}
hcRegion, err := hcIamRegion.SafeValueOf(region)
if err != nil {
return "", err
}
hcClient, err := hcIam.IamClientBuilder().
WithRegion(hcRegion).
WithCredential(auth).
SafeBuild()
if err != nil {
return "", err
}
client := hcIam.NewIamClient(hcClient)
if err != nil {
return "", err
}
request := &hcIamModel.KeystoneListProjectsRequest{
Name: &region,
}
response, err := client.KeystoneListProjects(request)
if err != nil {
return "", err
} else if response.Projects == nil || len(*response.Projects) == 0 {
return "", fmt.Errorf("no project found")
}
return (*response.Projects)[0].Id, nil
}

View File

@@ -15,9 +15,9 @@ import (
)
type HuaweiCloudSCMUploaderConfig struct {
Region string `json:"region"`
AccessKeyId string `json:"accessKeyId"`
SecretAccessKey string `json:"secretAccessKey"`
Region string `json:"region"`
}
type HuaweiCloudSCMUploader struct {
@@ -25,8 +25,12 @@ type HuaweiCloudSCMUploader struct {
sdkClient *hcScm.ScmClient
}
func NewHuaweiCloudSCMUploader(config *HuaweiCloudSCMUploaderConfig) (*HuaweiCloudSCMUploader, error) {
client, err := (&HuaweiCloudSCMUploader{config: config}).createSdkClient()
func NewHuaweiCloudSCMUploader(config *HuaweiCloudSCMUploaderConfig) (Uploader, error) {
client, err := (&HuaweiCloudSCMUploader{}).createSdkClient(
config.AccessKeyId,
config.SecretAccessKey,
config.Region,
)
if err != nil {
return nil, fmt.Errorf("failed to create sdk client: %w", err)
}
@@ -99,12 +103,12 @@ func (u *HuaweiCloudSCMUploader) Upload(ctx context.Context, certPem string, pri
if listCertificatesResp.Certificates == nil || len(*listCertificatesResp.Certificates) < int(listCertificatesLimit) {
break
}
listCertificatesOffset += listCertificatesLimit
listCertificatesPage += 1
if listCertificatesPage > 99 { // 避免死循环
break
} else {
listCertificatesOffset += listCertificatesLimit
listCertificatesPage += 1
if listCertificatesPage > 99 { // 避免死循环
break
}
}
}
@@ -133,10 +137,7 @@ func (u *HuaweiCloudSCMUploader) Upload(ctx context.Context, certPem string, pri
}, nil
}
func (u *HuaweiCloudSCMUploader) createSdkClient() (*hcScm.ScmClient, error) {
region := u.config.Region
accessKeyId := u.config.AccessKeyId
secretAccessKey := u.config.SecretAccessKey
func (u *HuaweiCloudSCMUploader) createSdkClient(accessKeyId, secretAccessKey, region string) (*hcScm.ScmClient, error) {
if region == "" {
region = "cn-north-4" // SCM 服务默认区域:华北四北京
}

View File

@@ -23,8 +23,12 @@ type TencentCloudSSLUploader struct {
sdkClient *tcSsl.Client
}
func NewTencentCloudSSLUploader(config *TencentCloudSSLUploaderConfig) (*TencentCloudSSLUploader, error) {
client, err := (&TencentCloudSSLUploader{config: config}).createSdkClient()
func NewTencentCloudSSLUploader(config *TencentCloudSSLUploaderConfig) (Uploader, error) {
client, err := (&TencentCloudSSLUploader{}).createSdkClient(
config.Region,
config.SecretId,
config.SecretKey,
)
if err != nil {
return nil, fmt.Errorf("failed to create sdk client: %w", err)
}
@@ -73,10 +77,7 @@ func (u *TencentCloudSSLUploader) Upload(ctx context.Context, certPem string, pr
}, nil
}
func (u *TencentCloudSSLUploader) createSdkClient() (*tcSsl.Client, error) {
region := u.config.Region
secretId := u.config.SecretId
secretKey := u.config.SecretKey
func (u *TencentCloudSSLUploader) createSdkClient(region, secretId, secretKey string) (*tcSsl.Client, error) {
if region == "" {
region = "ap-guangzhou" // SSL 服务默认区域:广州
}

View File

@@ -0,0 +1,51 @@
package fs
import (
"fmt"
"os"
"path/filepath"
)
// 与 [WriteFile] 类似,但写入的是字符串内容。
//
// 入参:
// - path: 文件路径。
// - content: 文件内容。
//
// 出参:
// - 错误。
func WriteFileString(path string, content string) error {
return WriteFile(path, []byte(content))
}
// 将数据写入指定路径的文件。
// 如果目录不存在,将会递归创建目录。
// 如果文件不存在,将会创建该文件;如果文件已存在,将会覆盖原有内容。
//
// 入参:
// - path: 文件路径。
// - data: 文件数据字节数组。
//
// 出参:
// - 错误。
func WriteFile(path string, data []byte) error {
dir := filepath.Dir(path)
err := os.MkdirAll(dir, os.ModePerm)
if err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
file, err := os.Create(path)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer file.Close()
_, err = file.Write(data)
if err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
return nil
}

View File

@@ -2,35 +2,12 @@
import (
"crypto/ecdsa"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
)
// 从 PEM 编码的证书字符串解析并返回一个 x509.Certificate 对象。
//
// 入参:
// - certPem: 证书 PEM 内容。
//
// 出参:
// - cert:
// - err:
func ParseCertificateFromPEM(certPem string) (cert *x509.Certificate, err error) {
pemData := []byte(certPem)
block, _ := pem.Decode(pemData)
if block == nil {
return nil, fmt.Errorf("failed to decode PEM block")
}
cert, err = x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse certificate: %w", err)
}
return cert, nil
}
// 比较两个 x509.Certificate 对象,判断它们是否是同一张证书。
// 注意,这不是精确比较,而只是基于证书序列号和数字签名的快速判断,但对于权威 CA 签发的证书来说不会存在误判。
//
@@ -48,9 +25,88 @@ func EqualCertificate(a, b *x509.Certificate) bool {
a.Subject.SerialNumber == b.Subject.SerialNumber
}
// 将 ECDSA 私钥转换为 PEM 格式的字符串
func PrivateKeyToPEM(privateKey *ecdsa.PrivateKey) (string, error) {
data, err := x509.MarshalECPrivateKey(privateKey)
// 从 PEM 编码的证书字符串解析并返回一个 x509.Certificate 对象
//
// 入参:
// - certPem: 证书 PEM 内容。
//
// 出参:
// - cert: x509.Certificate 对象。
// - err: 错误。
func ParseCertificateFromPEM(certPem string) (cert *x509.Certificate, err error) {
pemData := []byte(certPem)
block, _ := pem.Decode(pemData)
if block == nil {
return nil, fmt.Errorf("failed to decode PEM block")
}
cert, err = x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse certificate: %w", err)
}
return cert, nil
}
// 从 PEM 编码的私钥字符串解析并返回一个 ecdsa.PrivateKey 对象。
//
// 入参:
// - privkeyPem: 私钥 PEM 内容。
//
// 出参:
// - privkey: ecdsa.PrivateKey 对象。
// - err: 错误。
func ParseECPrivateKeyFromPEM(privkeyPem string) (privkey *ecdsa.PrivateKey, err error) {
pemData := []byte(privkeyPem)
block, _ := pem.Decode(pemData)
if block == nil {
return nil, fmt.Errorf("failed to decode PEM block")
}
privkey, err = x509.ParseECPrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse private key: %w", err)
}
return privkey, nil
}
// 从 PEM 编码的私钥字符串解析并返回一个 rsa.PrivateKey 对象。
//
// 入参:
// - privkeyPem: 私钥 PEM 内容。
//
// 出参:
// - privkey: rsa.PrivateKey 对象。
// - err: 错误。
func ParsePKCS1PrivateKeyFromPEM(privkeyPem string) (privkey *rsa.PrivateKey, err error) {
pemData := []byte(privkeyPem)
block, _ := pem.Decode(pemData)
if block == nil {
return nil, fmt.Errorf("failed to decode PEM block")
}
privkey, err = x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse private key: %w", err)
}
return privkey, nil
}
// 将 ecdsa.PrivateKey 对象转换为 PEM 编码的字符串。
//
// 入参:
// - privkey: ecdsa.PrivateKey 对象。
//
// 出参:
// - privkeyPem: 私钥 PEM 内容。
// - err: 错误。
func ConvertECPrivateKeyToPEM(privkey *ecdsa.PrivateKey) (privkeyPem string, err error) {
data, err := x509.MarshalECPrivateKey(privkey)
if err != nil {
return "", fmt.Errorf("failed to marshal EC private key: %w", err)
}
@@ -62,20 +118,3 @@ func PrivateKeyToPEM(privateKey *ecdsa.PrivateKey) (string, error) {
return string(pem.EncodeToMemory(block)), nil
}
// 从 PEM 编码的私钥字符串解析并返回一个 ECDSA 私钥对象。
func ParsePrivateKeyFromPEM(privateKeyPem string) (*ecdsa.PrivateKey, error) {
pemData := []byte(privateKeyPem)
block, _ := pem.Decode(pemData)
if block == nil {
return nil, fmt.Errorf("failed to decode PEM block")
}
privateKey, err := x509.ParseECPrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse private key: %w", err)
}
return privateKey, nil
}

View File

@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Certimate - Your Trusted SSL Automation Partner</title>
</head>
<body class="bg-background">
<body class="bg-background" style="pointer-events: auto !important">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>

701
ui/package-lock.json generated
View File

@@ -12,10 +12,11 @@
"@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-alert-dialog": "^1.1.1",
"@radix-ui/react-collapsible": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-navigation-menu": "^1.2.0",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-radio-group": "^1.2.0",
"@radix-ui/react-scroll-area": "^1.1.0",
@@ -28,6 +29,7 @@
"@radix-ui/react-tooltip": "^1.1.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"i18next": "^23.15.1",
"i18next-browser-languagedetector": "^8.0.0",
"i18next-http-backend": "^2.6.1",
@@ -1243,6 +1245,41 @@
}
}
},
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-dialog": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-dialog/-/react-dialog-1.1.1.tgz",
"integrity": "sha512-zysS+iU4YP3STKNS6USvFVqI4qqx8EpiwmT5TuCApVEBca+eRCbONi4EgzfNSuVnOXvC5UPHHMjs8RXO6DH9Bg==",
"dependencies": {
"@radix-ui/primitive": "1.1.0",
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-context": "1.1.0",
"@radix-ui/react-dismissable-layer": "1.1.0",
"@radix-ui/react-focus-guards": "1.1.0",
"@radix-ui/react-focus-scope": "1.1.0",
"@radix-ui/react-id": "1.1.0",
"@radix-ui/react-portal": "1.1.1",
"@radix-ui/react-presence": "1.1.0",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-slot": "1.1.0",
"@radix-ui/react-use-controllable-state": "1.1.0",
"aria-hidden": "^1.1.1",
"react-remove-scroll": "2.5.7"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-arrow": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-arrow/-/react-arrow-1.1.0.tgz",
@@ -1385,24 +1422,24 @@
}
},
"node_modules/@radix-ui/react-dialog": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-dialog/-/react-dialog-1.1.1.tgz",
"integrity": "sha512-zysS+iU4YP3STKNS6USvFVqI4qqx8EpiwmT5TuCApVEBca+eRCbONi4EgzfNSuVnOXvC5UPHHMjs8RXO6DH9Bg==",
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-dialog/-/react-dialog-1.1.2.tgz",
"integrity": "sha512-Yj4dZtqa2o+kG61fzB0H2qUvmwBA2oyQroGLyNtBj1beo1khoQ3q1a2AO8rrQYjd8256CO9+N8L9tvsS+bnIyA==",
"dependencies": {
"@radix-ui/primitive": "1.1.0",
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-context": "1.1.0",
"@radix-ui/react-dismissable-layer": "1.1.0",
"@radix-ui/react-focus-guards": "1.1.0",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.1",
"@radix-ui/react-focus-guards": "1.1.1",
"@radix-ui/react-focus-scope": "1.1.0",
"@radix-ui/react-id": "1.1.0",
"@radix-ui/react-portal": "1.1.1",
"@radix-ui/react-presence": "1.1.0",
"@radix-ui/react-portal": "1.1.2",
"@radix-ui/react-presence": "1.1.1",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-slot": "1.1.0",
"@radix-ui/react-use-controllable-state": "1.1.0",
"aria-hidden": "^1.1.1",
"react-remove-scroll": "2.5.7"
"react-remove-scroll": "2.6.0"
},
"peerDependencies": {
"@types/react": "*",
@@ -1419,6 +1456,130 @@
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-context": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-context/-/react-context-1.1.1.tgz",
"integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.1.tgz",
"integrity": "sha512-QSxg29lfr/xcev6kSz7MAlmDnzbP1eI/Dwn3Tp1ip0KT5CUELsxkekFEMVBEoykI3oV39hKT4TKZzBNMbcTZYQ==",
"dependencies": {
"@radix-ui/primitive": "1.1.0",
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-use-callback-ref": "1.1.0",
"@radix-ui/react-use-escape-keydown": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz",
"integrity": "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-portal": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-portal/-/react-portal-1.1.2.tgz",
"integrity": "sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==",
"dependencies": {
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-presence": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-presence/-/react-presence-1.1.1.tgz",
"integrity": "sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/react-remove-scroll": {
"version": "2.6.0",
"resolved": "https://registry.npmmirror.com/react-remove-scroll/-/react-remove-scroll-2.6.0.tgz",
"integrity": "sha512-I2U4JVEsQenxDAKaVa3VZ/JeJZe0/2DxPWL8Tj8yLKctQJQiZM52pn/GWFpSp8dftjM3pSAHVJZscAnC/y+ySQ==",
"dependencies": {
"react-remove-scroll-bar": "^2.3.6",
"react-style-singleton": "^2.2.1",
"tslib": "^2.1.0",
"use-callback-ref": "^1.3.0",
"use-sidecar": "^1.1.2"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-direction": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-direction/-/react-direction-1.1.0.tgz",
@@ -1638,6 +1799,166 @@
}
}
},
"node_modules/@radix-ui/react-popover": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-popover/-/react-popover-1.1.2.tgz",
"integrity": "sha512-u2HRUyWW+lOiA2g0Le0tMmT55FGOEWHwPFt1EPfbLly7uXQExFo5duNKqG2DzmFXIdqOeNd+TpE8baHWJCyP9w==",
"dependencies": {
"@radix-ui/primitive": "1.1.0",
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.1",
"@radix-ui/react-focus-guards": "1.1.1",
"@radix-ui/react-focus-scope": "1.1.0",
"@radix-ui/react-id": "1.1.0",
"@radix-ui/react-popper": "1.2.0",
"@radix-ui/react-portal": "1.1.2",
"@radix-ui/react-presence": "1.1.1",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-slot": "1.1.0",
"@radix-ui/react-use-controllable-state": "1.1.0",
"aria-hidden": "^1.1.1",
"react-remove-scroll": "2.6.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-context": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-context/-/react-context-1.1.1.tgz",
"integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.1.tgz",
"integrity": "sha512-QSxg29lfr/xcev6kSz7MAlmDnzbP1eI/Dwn3Tp1ip0KT5CUELsxkekFEMVBEoykI3oV39hKT4TKZzBNMbcTZYQ==",
"dependencies": {
"@radix-ui/primitive": "1.1.0",
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-use-callback-ref": "1.1.0",
"@radix-ui/react-use-escape-keydown": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz",
"integrity": "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-portal": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-portal/-/react-portal-1.1.2.tgz",
"integrity": "sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==",
"dependencies": {
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-presence": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-presence/-/react-presence-1.1.1.tgz",
"integrity": "sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/react-remove-scroll": {
"version": "2.6.0",
"resolved": "https://registry.npmmirror.com/react-remove-scroll/-/react-remove-scroll-2.6.0.tgz",
"integrity": "sha512-I2U4JVEsQenxDAKaVa3VZ/JeJZe0/2DxPWL8Tj8yLKctQJQiZM52pn/GWFpSp8dftjM3pSAHVJZscAnC/y+ySQ==",
"dependencies": {
"react-remove-scroll-bar": "^2.3.6",
"react-style-singleton": "^2.2.1",
"tslib": "^2.1.0",
"use-callback-ref": "^1.3.0",
"use-sidecar": "^1.1.2"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-popper/-/react-popper-1.2.0.tgz",
@@ -3039,6 +3360,366 @@
"node": ">=6"
}
},
"node_modules/cmdk": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/cmdk/-/cmdk-1.0.0.tgz",
"integrity": "sha512-gDzVf0a09TvoJ5jnuPvygTB77+XdOSwEmJ88L6XPFPlv7T3RxbP9jgenfylrAMD0+Le1aO0nVjQUzl2g+vjz5Q==",
"dependencies": {
"@radix-ui/react-dialog": "1.0.5",
"@radix-ui/react-primitive": "1.0.3"
},
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
},
"node_modules/cmdk/node_modules/@radix-ui/primitive": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/@radix-ui/primitive/-/primitive-1.0.1.tgz",
"integrity": "sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==",
"dependencies": {
"@babel/runtime": "^7.13.10"
}
},
"node_modules/cmdk/node_modules/@radix-ui/react-compose-refs": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz",
"integrity": "sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==",
"dependencies": {
"@babel/runtime": "^7.13.10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/cmdk/node_modules/@radix-ui/react-context": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-context/-/react-context-1.0.1.tgz",
"integrity": "sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==",
"dependencies": {
"@babel/runtime": "^7.13.10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/cmdk/node_modules/@radix-ui/react-dialog": {
"version": "1.0.5",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-dialog/-/react-dialog-1.0.5.tgz",
"integrity": "sha512-GjWJX/AUpB703eEBanuBnIWdIXg6NvJFCXcNlSZk4xdszCdhrJgBoUd1cGk67vFO+WdA2pfI/plOpqz/5GUP6Q==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/primitive": "1.0.1",
"@radix-ui/react-compose-refs": "1.0.1",
"@radix-ui/react-context": "1.0.1",
"@radix-ui/react-dismissable-layer": "1.0.5",
"@radix-ui/react-focus-guards": "1.0.1",
"@radix-ui/react-focus-scope": "1.0.4",
"@radix-ui/react-id": "1.0.1",
"@radix-ui/react-portal": "1.0.4",
"@radix-ui/react-presence": "1.0.1",
"@radix-ui/react-primitive": "1.0.3",
"@radix-ui/react-slot": "1.0.2",
"@radix-ui/react-use-controllable-state": "1.0.1",
"aria-hidden": "^1.1.1",
"react-remove-scroll": "2.5.5"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/cmdk/node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.0.5",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.5.tgz",
"integrity": "sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/primitive": "1.0.1",
"@radix-ui/react-compose-refs": "1.0.1",
"@radix-ui/react-primitive": "1.0.3",
"@radix-ui/react-use-callback-ref": "1.0.1",
"@radix-ui/react-use-escape-keydown": "1.0.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/cmdk/node_modules/@radix-ui/react-focus-guards": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.1.tgz",
"integrity": "sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA==",
"dependencies": {
"@babel/runtime": "^7.13.10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/cmdk/node_modules/@radix-ui/react-focus-scope": {
"version": "1.0.4",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.0.4.tgz",
"integrity": "sha512-sL04Mgvf+FmyvZeYfNu1EPAaaxD+aw7cYeIB9L9Fvq8+urhltTRaEo5ysKOpHuKPclsZcSUMKlN05x4u+CINpA==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-compose-refs": "1.0.1",
"@radix-ui/react-primitive": "1.0.3",
"@radix-ui/react-use-callback-ref": "1.0.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/cmdk/node_modules/@radix-ui/react-id": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-id/-/react-id-1.0.1.tgz",
"integrity": "sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-use-layout-effect": "1.0.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/cmdk/node_modules/@radix-ui/react-portal": {
"version": "1.0.4",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-portal/-/react-portal-1.0.4.tgz",
"integrity": "sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-primitive": "1.0.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/cmdk/node_modules/@radix-ui/react-presence": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-presence/-/react-presence-1.0.1.tgz",
"integrity": "sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-compose-refs": "1.0.1",
"@radix-ui/react-use-layout-effect": "1.0.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/cmdk/node_modules/@radix-ui/react-primitive": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz",
"integrity": "sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-slot": "1.0.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/cmdk/node_modules/@radix-ui/react-slot": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.0.2.tgz",
"integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-compose-refs": "1.0.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/cmdk/node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz",
"integrity": "sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==",
"dependencies": {
"@babel/runtime": "^7.13.10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/cmdk/node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz",
"integrity": "sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-use-callback-ref": "1.0.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/cmdk/node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.3.tgz",
"integrity": "sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-use-callback-ref": "1.0.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/cmdk/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz",
"integrity": "sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==",
"dependencies": {
"@babel/runtime": "^7.13.10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/cmdk/node_modules/react-remove-scroll": {
"version": "2.5.5",
"resolved": "https://registry.npmmirror.com/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz",
"integrity": "sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==",
"dependencies": {
"react-remove-scroll-bar": "^2.3.3",
"react-style-singleton": "^2.2.1",
"tslib": "^2.1.0",
"use-callback-ref": "^1.3.0",
"use-sidecar": "^1.1.2"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/color-convert": {
"version": "1.9.3",
"resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-1.9.3.tgz",

View File

@@ -14,10 +14,11 @@
"@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-alert-dialog": "^1.1.1",
"@radix-ui/react-collapsible": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-navigation-menu": "^1.2.0",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-radio-group": "^1.2.0",
"@radix-ui/react-scroll-area": "^1.1.0",
@@ -30,6 +31,7 @@
"@radix-ui/react-tooltip": "^1.1.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"i18next": "^23.15.1",
"i18next-browser-languagedetector": "^8.0.0",
"i18next-http-backend": "^2.6.1",

View File

@@ -5,7 +5,6 @@ import { cn } from "@/lib/utils";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from "@/components/ui/select";
import AccessAliyunForm from "./AccessAliyunForm";
import AccessTencentForm from "./AccessTencentForm";
import AccessHuaweiCloudForm from "./AccessHuaweicloudForm";
@@ -20,7 +19,8 @@ import AccessLocalForm from "./AccessLocalForm";
import AccessSSHForm from "./AccessSSHForm";
import AccessWebhookForm from "./AccessWebhookForm";
import AccessKubernetesForm from "./AccessKubernetesForm";
import { Access, accessProvidersMap } from "@/domain/access";
import { Access } from "@/domain/access";
import { AccessTypeSelect } from "./AccessTypeSelect";
type AccessEditProps = {
op: "add" | "edit" | "copy";
@@ -194,16 +194,26 @@ const AccessEditDialog = ({ trigger, op, data, className }: AccessEditProps) =>
break;
}
const getOptionCls = (val: string) => {
return val == configType ? "border-primary" : "";
};
return (
<Dialog onOpenChange={setOpen} open={open}>
<Dialog
onOpenChange={(openState) => {
if (openState) {
document.body.style.pointerEvents = "auto";
}
setOpen(openState);
}}
open={open}
modal={false}
>
<DialogTrigger asChild className={cn(className)}>
{trigger}
</DialogTrigger>
<DialogContent className="sm:max-w-[600px] w-full dark:text-stone-200">
<DialogContent
className="sm:max-w-[600px] w-full dark:text-stone-200"
onInteractOutside={(event) => {
event.preventDefault();
}}
>
<DialogHeader>
<DialogTitle>
{
@@ -219,29 +229,15 @@ const AccessEditDialog = ({ trigger, op, data, className }: AccessEditProps) =>
<div className="container py-3">
<div>
<Label>{t("access.authorization.form.type.label")}</Label>
<Select
onValueChange={(val) => {
<AccessTypeSelect
value={configType}
onChange={(val) => {
setConfigType(val);
}}
defaultValue={configType}
>
<SelectTrigger className="mt-3">
<SelectValue placeholder={t("access.authorization.form.type.placeholder")} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>{t("access.authorization.form.type.list")}</SelectLabel>
{Array.from(accessProvidersMap.entries()).map(([key, provider]) => (
<SelectItem value={key} key={key}>
<div className={cn("flex items-center space-x-2 rounded cursor-pointer", getOptionCls(key))}>
<img src={provider.icon} className="h-6 w-6" />
<div>{t(provider.name)}</div>
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
className="w-full mt-3"
placeholder={t("access.authorization.form.type.placeholder")}
searchPlaceholder={t("access.authorization.form.type.search.placeholder")}
/>
</div>
<div className="mt-8">{childComponent}</div>

View File

@@ -37,7 +37,7 @@ const AccessKubernetesForm = ({ data, op, onAfterReq }: AccessKubernetesFormProp
configType: accessTypeFormSchema,
kubeConfig: z
.string()
.min(1, "access.authorization.form.k8s_kubeconfig.placeholder")
.min(0, "access.authorization.form.k8s_kubeconfig.placeholder")
.max(20480, t("common.errmsg.string_max", { max: 20480 })),
kubeConfigFile: z.any().optional(),
});
@@ -191,3 +191,4 @@ const AccessKubernetesForm = ({ data, op, onAfterReq }: AccessKubernetesFormProp
};
export default AccessKubernetesForm;

View File

@@ -0,0 +1,80 @@
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { accessProvidersMap } from "@/domain/access";
import { useTranslation } from "react-i18next";
import { useEffect, useState } from "react";
type AccessTypeSelectProps = {
value: string;
onChange: (value: string) => void;
placeholder: string;
searchPlaceholder: string;
className?: string;
};
export function AccessTypeSelect({ value, onChange, placeholder, searchPlaceholder, className }: AccessTypeSelectProps) {
const [open, setOpen] = useState(false);
const [locValue, setLocValue] = useState("");
const { t } = useTranslation();
const [search, setSearch] = useState("");
const filteredProviders = Array.from(accessProvidersMap.entries());
useEffect(() => {
setLocValue(value);
}, [value]);
const handleOnSelect = (currentValue: string) => {
const newValue = currentValue === locValue ? "" : currentValue;
setLocValue(newValue);
setSearch("");
setOpen(false);
onChange(newValue);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" aria-expanded={open} className={cn("justify-between z-50", className)}>
{locValue ? (
<div className="flex space-x-2 items-center">
<img src={accessProvidersMap.get(locValue)?.icon} className="h-6 w-6" />
<div>{t(accessProvidersMap.get(locValue)?.name ?? "")}</div>
</div>
) : (
<>{placeholder}</>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className={cn("p-0 w-full")}>
<Command className="">
<CommandInput
placeholder={searchPlaceholder}
value={search}
onValueChange={(val: string) => {
setSearch(val);
}}
/>
<CommandList>
<CommandEmpty>{t("access.authorization.form.type.search.notfound")}</CommandEmpty>
<CommandGroup>
{filteredProviders.map(([key, provider]) => (
<CommandItem key={key} value={key} onSelect={handleOnSelect} keywords={provider.searchContent.split(":")}>
<Check className={cn("mr-2 h-4 w-4", locValue === key ? "opacity-100" : "opacity-0")} />
<div className="flex space-x-2">
<img src={provider.icon} className="h-6 w-6" />
<div className="font-medium">{t(provider.name)}</div>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -4,9 +4,9 @@ import { DeployConfig } from "@/domain/domain";
type DeployEditContext = {
deploy: DeployConfig;
error: Record<string, string>;
error: Record<string, string | undefined>;
setDeploy: (deploy: DeployConfig) => void;
setError: (error: Record<string, string>) => void;
setError: (error: Record<string, string | undefined>) => void;
};
export const Context = createContext<DeployEditContext>({} as DeployEditContext);

View File

@@ -11,11 +11,17 @@ import AccessEditDialog from "./AccessEditDialog";
import { Context as DeployEditContext } from "./DeployEdit";
import DeployToAliyunOSS from "./DeployToAliyunOSS";
import DeployToAliyunCDN from "./DeployToAliyunCDN";
import DeployToAliyunCLB from "./DeployToAliyunCLB";
import DeployToAliyunALB from "./DeployToAliyunALB";
import DeployToAliyunNLB from "./DeployToAliyunNLB";
import DeployToTencentCDN from "./DeployToTencentCDN";
import DeployToTencentCLB from "./DeployToTencentCLB";
import DeployToTencentCOS from "./DeployToTencentCOS";
import DeployToTencentTEO from "./DeployToTencentTEO";
import DeployToHuaweiCloudCDN from "./DeployToHuaweiCloudCDN";
import DeployToHuaweiCloudELB from "./DeployToHuaweiCloudELB";
import DeployToQiniuCDN from "./DeployToQiniuCDN";
import DeployToLocal from "./DeployToLocal";
import DeployToSSH from "./DeployToSSH";
import DeployToWebhook from "./DeployToWebhook";
import DeployToKubernetesSecret from "./DeployToKubernetesSecret";
@@ -43,7 +49,7 @@ const DeployEditDialog = ({ trigger, deployConfig, onSave }: DeployEditDialogPro
type: "",
});
const [error, setError] = useState<Record<string, string>>({});
const [error, setError] = useState<Record<string, string | undefined>>({});
const [open, setOpen] = useState(false);
@@ -83,7 +89,7 @@ const DeployEditDialog = ({ trigger, deployConfig, onSave }: DeployEditDialogPro
return true;
}
return item.configType === locDeployConfig.type.split("-")[0];
return item.configType === deployTargetsMap.get(locDeployConfig.type)?.provider;
});
const handleSaveClick = () => {
@@ -117,7 +123,17 @@ const DeployEditDialog = ({ trigger, deployConfig, onSave }: DeployEditDialogPro
case "aliyun-dcdn":
childComponent = <DeployToAliyunCDN />;
break;
case "aliyun-clb":
childComponent = <DeployToAliyunCLB />;
break;
case "aliyun-alb":
childComponent = <DeployToAliyunALB />;
break;
case "aliyun-nlb":
childComponent = <DeployToAliyunNLB />;
break;
case "tencent-cdn":
case "tencent-ecdn":
childComponent = <DeployToTencentCDN />;
break;
case "tencent-clb":
@@ -126,14 +142,22 @@ const DeployEditDialog = ({ trigger, deployConfig, onSave }: DeployEditDialogPro
case "tencent-cos":
childComponent = <DeployToTencentCOS />;
break;
case "tencent-teo":
childComponent = <DeployToTencentTEO />;
break;
case "huaweicloud-cdn":
childComponent = <DeployToHuaweiCloudCDN />;
break;
case "huaweicloud-elb":
childComponent = <DeployToHuaweiCloudELB />;
break;
case "qiniu-cdn":
childComponent = <DeployToQiniuCDN />;
break;
case "ssh":
case "local":
childComponent = <DeployToLocal />;
break;
case "ssh":
childComponent = <DeployToSSH />;
break;
case "webhook":
@@ -155,7 +179,12 @@ const DeployEditDialog = ({ trigger, deployConfig, onSave }: DeployEditDialogPro
>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger>{trigger}</DialogTrigger>
<DialogContent className="dark:text-stone-200">
<DialogContent
className="dark:text-stone-200"
onInteractOutside={(event) => {
event.preventDefault();
}}
>
<DialogHeader>
<DialogTitle>{t("domain.deployment.tab")}</DialogTitle>
<DialogDescription></DialogDescription>

View File

@@ -0,0 +1,162 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { produce } from "immer";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useDeployEditContext } from "./DeployEdit";
const DeployToAliyunALB = () => {
const { t } = useTranslation();
const { deploy: data, setDeploy, error, setError } = useDeployEditContext();
useEffect(() => {
if (!data.id) {
setDeploy({
...data,
config: {
region: "cn-hangzhou",
resourceType: "",
loadbalancerId: "",
listenerId: "",
},
});
}
}, []);
useEffect(() => {
setError({});
}, []);
const formSchema = z
.object({
region: z.string().min(1, t("domain.deployment.form.aliyun_alb_region.placeholder")),
resourceType: z.union([z.literal("loadbalancer"), z.literal("listener")], {
message: t("domain.deployment.form.aliyun_alb_resource_type.placeholder"),
}),
loadbalancerId: z.string().optional(),
listenerId: z.string().optional(),
})
.refine((data) => (data.resourceType === "loadbalancer" ? !!data.loadbalancerId?.trim() : true), {
message: t("domain.deployment.form.aliyun_alb_loadbalancer_id.placeholder"),
path: ["loadbalancerId"],
})
.refine((data) => (data.resourceType === "listener" ? !!data.listenerId?.trim() : true), {
message: t("domain.deployment.form.aliyun_alb_listener_id.placeholder"),
path: ["listenerId"],
});
useEffect(() => {
const res = formSchema.safeParse(data.config);
if (!res.success) {
setError({
...error,
region: res.error.errors.find((e) => e.path[0] === "region")?.message,
resourceType: res.error.errors.find((e) => e.path[0] === "resourceType")?.message,
loadbalancerId: res.error.errors.find((e) => e.path[0] === "loadbalancerId")?.message,
listenerId: res.error.errors.find((e) => e.path[0] === "listenerId")?.message,
});
} else {
setError({
...error,
region: undefined,
resourceType: undefined,
loadbalancerId: undefined,
listenerId: undefined,
});
}
}, [data]);
return (
<div className="flex flex-col space-y-8">
<div>
<Label>{t("domain.deployment.form.aliyun_alb_region.label")}</Label>
<Input
placeholder={t("domain.deployment.form.aliyun_alb_region.placeholder")}
className="w-full mt-1"
value={data?.config?.region}
onChange={(e) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.region = e.target.value?.trim();
});
setDeploy(newData);
}}
/>
<div className="text-red-600 text-sm mt-1">{error?.region}</div>
</div>
<div>
<Label>{t("domain.deployment.form.aliyun_alb_resource_type.label")}</Label>
<Select
value={data?.config?.resourceType}
onValueChange={(value) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.resourceType = value?.trim();
});
setDeploy(newData);
}}
>
<SelectTrigger>
<SelectValue placeholder={t("domain.deployment.form.aliyun_alb_resource_type.placeholder")} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="loadbalancer">{t("domain.deployment.form.aliyun_alb_resource_type.option.loadbalancer.label")}</SelectItem>
<SelectItem value="listener">{t("domain.deployment.form.aliyun_alb_resource_type.option.listener.label")}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<div className="text-red-600 text-sm mt-1">{error?.resourceType}</div>
</div>
{data?.config?.resourceType === "loadbalancer" ? (
<div>
<Label>{t("domain.deployment.form.aliyun_alb_loadbalancer_id.label")}</Label>
<Input
placeholder={t("domain.deployment.form.aliyun_alb_loadbalancer_id.placeholder")}
className="w-full mt-1"
value={data?.config?.loadbalancerId}
onChange={(e) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.loadbalancerId = e.target.value?.trim();
});
setDeploy(newData);
}}
/>
<div className="text-red-600 text-sm mt-1">{error?.loadbalancerId}</div>
</div>
) : (
<></>
)}
{data?.config?.resourceType === "listener" ? (
<div>
<Label>{t("domain.deployment.form.aliyun_alb_listener_id.label")}</Label>
<Input
placeholder={t("domain.deployment.form.aliyun_alb_listener_id.placeholder")}
className="w-full mt-1"
value={data?.config?.listenerId}
onChange={(e) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.listenerId = e.target.value?.trim();
});
setDeploy(newData);
}}
/>
<div className="text-red-600 text-sm mt-1">{error?.listenerId}</div>
</div>
) : (
<></>
)}
</div>
);
};
export default DeployToAliyunALB;

View File

@@ -12,6 +12,17 @@ const DeployToAliyunCDN = () => {
const { deploy: data, setDeploy, error, setError } = useDeployEditContext();
useEffect(() => {
if (!data.id) {
setDeploy({
...data,
config: {
domain: "",
},
});
}
}, []);
useEffect(() => {
setError({});
}, []);

View File

@@ -0,0 +1,158 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { produce } from "immer";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useDeployEditContext } from "./DeployEdit";
const DeployToAliyunCLB = () => {
const { t } = useTranslation();
const { deploy: data, setDeploy, error, setError } = useDeployEditContext();
useEffect(() => {
if (!data.id) {
setDeploy({
...data,
config: {
region: "cn-hangzhou",
resourceType: "",
loadbalancerId: "",
listenerPort: "443",
},
});
}
}, []);
useEffect(() => {
setError({});
}, []);
const formSchema = z
.object({
region: z.string().min(1, t("domain.deployment.form.aliyun_clb_region.placeholder")),
resourceType: z.union([z.literal("certificate"), z.literal("loadbalancer"), z.literal("listener")], {
message: t("domain.deployment.form.aliyun_clb_resource_type.placeholder"),
}),
loadbalancerId: z.string().optional(),
listenerPort: z.string().optional(),
})
.refine((data) => (data.resourceType === "loadbalancer" || data.resourceType === "listener" ? !!data.loadbalancerId?.trim() : true), {
message: t("domain.deployment.form.aliyun_clb_loadbalancer_id.placeholder"),
path: ["loadbalancerId"],
})
.refine((data) => (data.resourceType === "listener" ? +data.listenerPort! > 0 && +data.listenerPort! < 65535 : true), {
message: t("domain.deployment.form.aliyun_clb_listener_port.placeholder"),
path: ["listenerPort"],
});
useEffect(() => {
const res = formSchema.safeParse(data.config);
if (!res.success) {
setError({
...error,
region: res.error.errors.find((e) => e.path[0] === "region")?.message,
resourceType: res.error.errors.find((e) => e.path[0] === "resourceType")?.message,
loadbalancerId: res.error.errors.find((e) => e.path[0] === "loadbalancerId")?.message,
listenerPort: res.error.errors.find((e) => e.path[0] === "listenerPort")?.message,
});
} else {
setError({
...error,
region: undefined,
resourceType: undefined,
loadbalancerId: undefined,
listenerPort: undefined,
});
}
}, [data]);
return (
<div className="flex flex-col space-y-8">
<div>
<Label>{t("domain.deployment.form.aliyun_clb_region.label")}</Label>
<Input
placeholder={t("domain.deployment.form.aliyun_clb_region.placeholder")}
className="w-full mt-1"
value={data?.config?.region}
onChange={(e) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.region = e.target.value?.trim();
});
setDeploy(newData);
}}
/>
<div className="text-red-600 text-sm mt-1">{error?.region}</div>
</div>
<div>
<Label>{t("domain.deployment.form.aliyun_clb_resource_type.label")}</Label>
<Select
value={data?.config?.resourceType}
onValueChange={(value) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.resourceType = value?.trim();
});
setDeploy(newData);
}}
>
<SelectTrigger>
<SelectValue placeholder={t("domain.deployment.form.aliyun_clb_resource_type.placeholder")} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="loadbalancer">{t("domain.deployment.form.aliyun_clb_resource_type.option.loadbalancer.label")}</SelectItem>
<SelectItem value="listener">{t("domain.deployment.form.aliyun_clb_resource_type.option.listener.label")}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<div className="text-red-600 text-sm mt-1">{error?.resourceType}</div>
</div>
<div>
<Label>{t("domain.deployment.form.aliyun_clb_loadbalancer_id.label")}</Label>
<Input
placeholder={t("domain.deployment.form.aliyun_clb_loadbalancer_id.placeholder")}
className="w-full mt-1"
value={data?.config?.loadbalancerId}
onChange={(e) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.loadbalancerId = e.target.value?.trim();
});
setDeploy(newData);
}}
/>
<div className="text-red-600 text-sm mt-1">{error?.loadbalancerId}</div>
</div>
{data?.config?.resourceType === "listener" ? (
<div>
<Label>{t("domain.deployment.form.aliyun_clb_listener_port.label")}</Label>
<Input
placeholder={t("domain.deployment.form.aliyun_clb_listener_port.placeholder")}
className="w-full mt-1"
value={data?.config?.listenerPort}
onChange={(e) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.listenerPort = e.target.value?.trim();
});
setDeploy(newData);
}}
/>
<div className="text-red-600 text-sm mt-1">{error?.listenerPort}</div>
</div>
) : (
<></>
)}
</div>
);
};
export default DeployToAliyunCLB;

View File

@@ -0,0 +1,162 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { produce } from "immer";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useDeployEditContext } from "./DeployEdit";
const DeployToAliyunNLB = () => {
const { t } = useTranslation();
const { deploy: data, setDeploy, error, setError } = useDeployEditContext();
useEffect(() => {
if (!data.id) {
setDeploy({
...data,
config: {
region: "cn-hangzhou",
resourceType: "",
loadbalancerId: "",
listenerId: "",
},
});
}
}, []);
useEffect(() => {
setError({});
}, []);
const formSchema = z
.object({
region: z.string().min(1, t("domain.deployment.form.aliyun_nlb_region.placeholder")),
resourceType: z.union([z.literal("loadbalancer"), z.literal("listener")], {
message: t("domain.deployment.form.aliyun_nlb_resource_type.placeholder"),
}),
loadbalancerId: z.string().optional(),
listenerId: z.string().optional(),
})
.refine((data) => (data.resourceType === "loadbalancer" ? !!data.loadbalancerId?.trim() : true), {
message: t("domain.deployment.form.aliyun_nlb_loadbalancer_id.placeholder"),
path: ["loadbalancerId"],
})
.refine((data) => (data.resourceType === "listener" ? !!data.listenerId?.trim() : true), {
message: t("domain.deployment.form.aliyun_nlb_listener_id.placeholder"),
path: ["listenerId"],
});
useEffect(() => {
const res = formSchema.safeParse(data.config);
if (!res.success) {
setError({
...error,
region: res.error.errors.find((e) => e.path[0] === "region")?.message,
resourceType: res.error.errors.find((e) => e.path[0] === "resourceType")?.message,
loadbalancerId: res.error.errors.find((e) => e.path[0] === "loadbalancerId")?.message,
listenerId: res.error.errors.find((e) => e.path[0] === "listenerId")?.message,
});
} else {
setError({
...error,
region: undefined,
resourceType: undefined,
loadbalancerId: undefined,
listenerId: undefined,
});
}
}, [data]);
return (
<div className="flex flex-col space-y-8">
<div>
<Label>{t("domain.deployment.form.aliyun_nlb_region.label")}</Label>
<Input
placeholder={t("domain.deployment.form.aliyun_nlb_region.placeholder")}
className="w-full mt-1"
value={data?.config?.region}
onChange={(e) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.region = e.target.value?.trim();
});
setDeploy(newData);
}}
/>
<div className="text-red-600 text-sm mt-1">{error?.region}</div>
</div>
<div>
<Label>{t("domain.deployment.form.aliyun_nlb_resource_type.label")}</Label>
<Select
value={data?.config?.resourceType}
onValueChange={(value) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.resourceType = value?.trim();
});
setDeploy(newData);
}}
>
<SelectTrigger>
<SelectValue placeholder={t("domain.deployment.form.aliyun_nlb_resource_type.placeholder")} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="loadbalancer">{t("domain.deployment.form.aliyun_nlb_resource_type.option.loadbalancer.label")}</SelectItem>
<SelectItem value="listener">{t("domain.deployment.form.aliyun_nlb_resource_type.option.listener.label")}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<div className="text-red-600 text-sm mt-1">{error?.resourceType}</div>
</div>
{data?.config?.resourceType === "loadbalancer" ? (
<div>
<Label>{t("domain.deployment.form.aliyun_nlb_loadbalancer_id.label")}</Label>
<Input
placeholder={t("domain.deployment.form.aliyun_nlb_loadbalancer_id.placeholder")}
className="w-full mt-1"
value={data?.config?.loadbalancerId}
onChange={(e) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.loadbalancerId = e.target.value?.trim();
});
setDeploy(newData);
}}
/>
<div className="text-red-600 text-sm mt-1">{error?.loadbalancerId}</div>
</div>
) : (
<></>
)}
{data?.config?.resourceType === "listener" ? (
<div>
<Label>{t("domain.deployment.form.aliyun_nlb_listener_id.label")}</Label>
<Input
placeholder={t("domain.deployment.form.aliyun_nlb_listener_id.placeholder")}
className="w-full mt-1"
value={data?.config?.listenerId}
onChange={(e) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.listenerId = e.target.value?.trim();
});
setDeploy(newData);
}}
/>
<div className="text-red-600 text-sm mt-1">{error?.listenerId}</div>
</div>
) : (
<></>
)}
</div>
);
};
export default DeployToAliyunNLB;

View File

@@ -8,9 +8,22 @@ import { Label } from "@/components/ui/label";
import { useDeployEditContext } from "./DeployEdit";
const DeployToAliyunOSS = () => {
const { t } = useTranslation();
const { deploy: data, setDeploy, error, setError } = useDeployEditContext();
const { t } = useTranslation();
useEffect(() => {
if (!data.id) {
setDeploy({
...data,
config: {
endpoint: "oss-cn-hangzhou.aliyuncs.com",
bucket: "",
domain: "",
},
});
}
}, []);
useEffect(() => {
setError({});
@@ -32,11 +45,11 @@ const DeployToAliyunOSS = () => {
}, [data]);
useEffect(() => {
const bucketResp = bucketSchema.safeParse(data.config?.domain);
if (!bucketResp.success) {
const resp = bucketSchema.safeParse(data.config?.bucket);
if (!resp.success) {
setError({
...error,
bucket: JSON.parse(bucketResp.error.message)[0].message,
bucket: JSON.parse(resp.error.message)[0].message,
});
} else {
setError({
@@ -44,35 +57,22 @@ const DeployToAliyunOSS = () => {
bucket: "",
});
}
}, []);
useEffect(() => {
if (!data.id) {
setDeploy({
...data,
config: {
endpoint: "oss-cn-hangzhou.aliyuncs.com",
bucket: "",
domain: "",
},
});
}
}, []);
}, [data]);
const domainSchema = z.string().regex(/^(?:\*\.)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/, {
message: t("common.errmsg.domain_invalid"),
});
const bucketSchema = z.string().min(1, {
message: t("domain.deployment.form.oss_bucket.placeholder"),
message: t("domain.deployment.form.aliyun_oss_bucket.placeholder"),
});
return (
<div className="flex flex-col space-y-8">
<div>
<Label>{t("domain.deployment.form.oss_endpoint.label")}</Label>
<Label>{t("domain.deployment.form.aliyun_oss_endpoint.label")}</Label>
<Input
placeholder={t("domain.deployment.form.oss_endpoint.placeholder")}
placeholder={t("domain.deployment.form.aliyun_oss_endpoint.placeholder")}
className="w-full mt-1"
value={data?.config?.endpoint}
onChange={(e) => {
@@ -91,9 +91,9 @@ const DeployToAliyunOSS = () => {
</div>
<div>
<Label>{t("domain.deployment.form.oss_bucket.label")}</Label>
<Label>{t("domain.deployment.form.aliyun_oss_bucket.label")}</Label>
<Input
placeholder={t("domain.deployment.form.oss_bucket.placeholder")}
placeholder={t("domain.deployment.form.aliyun_oss_bucket.placeholder")}
className="w-full mt-1"
value={data?.config?.bucket}
onChange={(e) => {

View File

@@ -12,6 +12,18 @@ const DeployToHuaweiCloudCDN = () => {
const { deploy: data, setDeploy, error, setError } = useDeployEditContext();
useEffect(() => {
if (!data.id) {
setDeploy({
...data,
config: {
region: "cn-north-1",
domain: "",
},
});
}
}, []);
useEffect(() => {
setError({});
}, []);
@@ -37,6 +49,23 @@ const DeployToHuaweiCloudCDN = () => {
return (
<div className="flex flex-col space-y-8">
<div>
<Label>{t("domain.deployment.form.huaweicloud_elb_region.label")}</Label>
<Input
placeholder={t("domain.deployment.form.huaweicloud_elb_region.placeholder")}
className="w-full mt-1"
value={data?.config?.region}
onChange={(e) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.region = e.target.value?.trim();
});
setDeploy(newData);
}}
/>
<div className="text-red-600 text-sm mt-1">{error?.region}</div>
</div>
<div>
<Label>{t("domain.deployment.form.domain.label")}</Label>
<Input
@@ -44,26 +73,9 @@ const DeployToHuaweiCloudCDN = () => {
className="w-full mt-1"
value={data?.config?.domain}
onChange={(e) => {
const temp = e.target.value;
const resp = domainSchema.safeParse(temp);
if (!resp.success) {
setError({
...error,
domain: JSON.parse(resp.error.message)[0].message,
});
} else {
setError({
...error,
domain: "",
});
}
const newData = produce(data, (draft) => {
if (!draft.config) {
draft.config = {};
}
draft.config.domain = temp;
draft.config ??= {};
draft.config.domain = e.target.value?.trim();
});
setDeploy(newData);
}}

View File

@@ -0,0 +1,190 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { produce } from "immer";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useDeployEditContext } from "./DeployEdit";
const DeployToHuaweiCloudCDN = () => {
const { t } = useTranslation();
const { deploy: data, setDeploy, error, setError } = useDeployEditContext();
useEffect(() => {
if (!data.id) {
setDeploy({
...data,
config: {
region: "cn-north-1",
resourceType: "",
certificateId: "",
loadbalancerId: "",
listenerId: "",
},
});
}
}, []);
useEffect(() => {
setError({});
}, []);
const formSchema = z
.object({
region: z.string().min(1, t("domain.deployment.form.huaweicloud_elb_region.placeholder")),
resourceType: z.string().min(1, t("domain.deployment.form.huaweicloud_elb_resource_type.placeholder")),
certificateId: z.string().optional(),
loadbalancerId: z.string().optional(),
listenerId: z.string().optional(),
})
.refine((data) => (data.resourceType === "certificate" ? !!data.certificateId?.trim() : true), {
message: t("domain.deployment.form.huaweicloud_elb_certificate_id.placeholder"),
path: ["certificateId"],
})
.refine((data) => (data.resourceType === "loadbalancer" ? !!data.loadbalancerId?.trim() : true), {
message: t("domain.deployment.form.huaweicloud_elb_loadbalancer_id.placeholder"),
path: ["loadbalancerId"],
})
.refine((data) => (data.resourceType === "listener" ? !!data.listenerId?.trim() : true), {
message: t("domain.deployment.form.huaweicloud_elb_listener_id.placeholder"),
path: ["listenerId"],
});
useEffect(() => {
const res = formSchema.safeParse(data.config);
if (!res.success) {
setError({
...error,
region: res.error.errors.find((e) => e.path[0] === "region")?.message,
resourceType: res.error.errors.find((e) => e.path[0] === "resourceType")?.message,
certificateId: res.error.errors.find((e) => e.path[0] === "certificateId")?.message,
loadbalancerId: res.error.errors.find((e) => e.path[0] === "loadbalancerId")?.message,
listenerId: res.error.errors.find((e) => e.path[0] === "listenerId")?.message,
});
} else {
setError({
...error,
region: undefined,
resourceType: undefined,
certificateId: undefined,
loadbalancerId: undefined,
listenerId: undefined,
});
}
}, [data]);
return (
<div className="flex flex-col space-y-8">
<div>
<Label>{t("domain.deployment.form.huaweicloud_elb_region.label")}</Label>
<Input
placeholder={t("domain.deployment.form.huaweicloud_elb_region.placeholder")}
className="w-full mt-1"
value={data?.config?.region}
onChange={(e) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.region = e.target.value?.trim();
});
setDeploy(newData);
}}
/>
<div className="text-red-600 text-sm mt-1">{error?.region}</div>
</div>
<div>
<Label>{t("domain.deployment.form.huaweicloud_elb_resource_type.label")}</Label>
<Select
value={data?.config?.resourceType}
onValueChange={(value) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.resourceType = value;
});
setDeploy(newData);
}}
>
<SelectTrigger>
<SelectValue placeholder={t("domain.deployment.form.huaweicloud_elb_resource_type.placeholder")} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="certificate">{t("domain.deployment.form.huaweicloud_elb_resource_type.option.certificate.label")}</SelectItem>
<SelectItem value="loadbalancer">{t("domain.deployment.form.huaweicloud_elb_resource_type.option.loadbalancer.label")}</SelectItem>
<SelectItem value="listener">{t("domain.deployment.form.huaweicloud_elb_resource_type.option.listener.label")}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<div className="text-red-600 text-sm mt-1">{error?.resourceType}</div>
</div>
{data?.config?.resourceType === "certificate" ? (
<div>
<Label>{t("domain.deployment.form.huaweicloud_elb_certificate_id.label")}</Label>
<Input
placeholder={t("domain.deployment.form.huaweicloud_elb_certificate_id.placeholder")}
className="w-full mt-1"
value={data?.config?.certificateId}
onChange={(e) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.certificateId = e.target.value?.trim();
});
setDeploy(newData);
}}
/>
<div className="text-red-600 text-sm mt-1">{error?.certificateId}</div>
</div>
) : (
<></>
)}
{data?.config?.resourceType === "loadbalancer" ? (
<div>
<Label>{t("domain.deployment.form.huaweicloud_elb_loadbalancer_id.label")}</Label>
<Input
placeholder={t("domain.deployment.form.huaweicloud_elb_loadbalancer_id.placeholder")}
className="w-full mt-1"
value={data?.config?.loadbalancerId}
onChange={(e) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.loadbalancerId = e.target.value?.trim();
});
setDeploy(newData);
}}
/>
<div className="text-red-600 text-sm mt-1">{error?.loadbalancerId}</div>
</div>
) : (
<></>
)}
{data?.config?.resourceType === "listener" ? (
<div>
<Label>{t("domain.deployment.form.huaweicloud_elb_listener_id.label")}</Label>
<Input
placeholder={t("domain.deployment.form.huaweicloud_elb_listener_id.placeholder")}
className="w-full mt-1"
value={data?.config?.listenerId}
onChange={(e) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.listenerId = e.target.value?.trim();
});
setDeploy(newData);
}}
/>
<div className="text-red-600 text-sm mt-1">{error?.listenerId}</div>
</div>
) : (
<></>
)}
</div>
);
};
export default DeployToHuaweiCloudCDN;

View File

@@ -8,13 +8,8 @@ import { useDeployEditContext } from "./DeployEdit";
const DeployToKubernetesSecret = () => {
const { t } = useTranslation();
const { setError } = useDeployEditContext();
useEffect(() => {
setError({});
}, []);
const { deploy: data, setDeploy } = useDeployEditContext();
const { deploy: data, setDeploy, setError } = useDeployEditContext();
useEffect(() => {
if (!data.id) {
@@ -30,6 +25,10 @@ const DeployToKubernetesSecret = () => {
}
}, []);
useEffect(() => {
setError({});
}, []);
return (
<>
<div className="flex flex-col space-y-8">

View File

@@ -0,0 +1,450 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { produce } from "immer";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { useDeployEditContext } from "./DeployEdit";
import { cn } from "@/lib/utils";
const DeployToLocal = () => {
const { t } = useTranslation();
const { deploy: data, setDeploy, error, setError } = useDeployEditContext();
useEffect(() => {
if (!data.id) {
setDeploy({
...data,
config: {
format: "pem",
certPath: "/etc/nginx/ssl/nginx.crt",
keyPath: "/etc/nginx/ssl/nginx.key",
pfxPassword: "",
jksAlias: "",
jksKeypass: "",
jksStorepass: "",
shell: "sh",
preCommand: "",
command: "sudo service nginx reload",
},
});
}
}, []);
useEffect(() => {
setError({});
}, []);
const formSchema = z
.object({
format: z.union([z.literal("pem"), z.literal("pfx"), z.literal("jks")], {
message: t("domain.deployment.form.file_format.placeholder"),
}),
certPath: z
.string()
.min(1, t("domain.deployment.form.file_cert_path.placeholder"))
.max(255, t("common.errmsg.string_max", { max: 255 })),
keyPath: z
.string()
.min(0, t("domain.deployment.form.file_key_path.placeholder"))
.max(255, t("common.errmsg.string_max", { max: 255 })),
pfxPassword: z.string().optional(),
jksAlias: z.string().optional(),
jksKeypass: z.string().optional(),
jksStorepass: z.string().optional(),
shell: z.union([z.literal("sh"), z.literal("cmd"), z.literal("powershell")], {
message: t("domain.deployment.form.shell.placeholder"),
}),
preCommand: z.string().optional(),
command: z.string().optional(),
})
.refine((data) => (data.format === "pem" ? !!data.keyPath?.trim() : true), {
message: t("domain.deployment.form.file_key_path.placeholder"),
path: ["keyPath"],
})
.refine((data) => (data.format === "pfx" ? !!data.pfxPassword?.trim() : true), {
message: t("domain.deployment.form.file_pfx_password.placeholder"),
path: ["pfxPassword"],
})
.refine((data) => (data.format === "jks" ? !!data.jksAlias?.trim() : true), {
message: t("domain.deployment.form.file_jks_alias.placeholder"),
path: ["jksAlias"],
})
.refine((data) => (data.format === "jks" ? !!data.jksKeypass?.trim() : true), {
message: t("domain.deployment.form.file_jks_keypass.placeholder"),
path: ["jksKeypass"],
})
.refine((data) => (data.format === "jks" ? !!data.jksStorepass?.trim() : true), {
message: t("domain.deployment.form.file_jks_storepass.placeholder"),
path: ["jksStorepass"],
});
useEffect(() => {
const res = formSchema.safeParse(data.config);
if (!res.success) {
setError({
...error,
format: res.error.errors.find((e) => e.path[0] === "format")?.message,
certPath: res.error.errors.find((e) => e.path[0] === "certPath")?.message,
keyPath: res.error.errors.find((e) => e.path[0] === "keyPath")?.message,
pfxPassword: res.error.errors.find((e) => e.path[0] === "pfxPassword")?.message,
jksAlias: res.error.errors.find((e) => e.path[0] === "jksAlias")?.message,
jksKeypass: res.error.errors.find((e) => e.path[0] === "jksKeypass")?.message,
jksStorepass: res.error.errors.find((e) => e.path[0] === "jksStorepass")?.message,
shell: res.error.errors.find((e) => e.path[0] === "shell")?.message,
preCommand: res.error.errors.find((e) => e.path[0] === "preCommand")?.message,
command: res.error.errors.find((e) => e.path[0] === "command")?.message,
});
} else {
setError({
...error,
format: undefined,
certPath: undefined,
keyPath: undefined,
pfxPassword: undefined,
jksAlias: undefined,
jksKeypass: undefined,
jksStorepass: undefined,
shell: undefined,
preCommand: undefined,
command: undefined,
});
}
}, [data]);
useEffect(() => {
if (data.config?.format === "pem") {
if (/(.pfx|.jks)$/.test(data.config.certPath)) {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.certPath = data.config!.certPath.replace(/(.pfx|.jks)$/, ".crt");
});
setDeploy(newData);
}
} else if (data.config?.format === "pfx") {
if (/(.crt|.jks)$/.test(data.config.certPath)) {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.certPath = data.config!.certPath.replace(/(.crt|.jks)$/, ".pfx");
});
setDeploy(newData);
}
} else if (data.config?.format === "jks") {
if (/(.crt|.pfx)$/.test(data.config.certPath)) {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.certPath = data.config!.certPath.replace(/(.crt|.pfx)$/, ".jks");
});
setDeploy(newData);
}
}
}, [data.config?.format]);
const getOptionCls = (val: string) => {
if (data.config?.shell === val) {
return "border-primary dark:border-primary";
}
return "";
};
const handleUsePresetScript = (key: string) => {
switch (key) {
case "reload_nginx":
{
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.shell = "sh";
draft.config.command = "sudo service nginx reload";
});
setDeploy(newData);
}
break;
case "binding_iis":
{
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.shell = "powershell";
draft.config.command = `
# 请将以下变量替换为实际值
$pfxPath = "<your-pfx-path>" # PFX 文件路径
$pfxPassword = "<your-pfx-password>" # PFX 密码
$siteName = "<your-site-name>" # IIS 网站名称
$domain = "<your-domain-name>" # 域名
$ipaddr = "<your-binding-ip>" # 绑定 IP“*”表示所有 IP 绑定
$port = "<your-binding-port>" # 绑定端口
# 导入证书到本地计算机的个人存储区
$cert = Import-PfxCertificate -FilePath "$pfxPath" -CertStoreLocation Cert:\\LocalMachine\\My -Password (ConvertTo-SecureString -String "$pfxPassword" -AsPlainText -Force) -Exportable
# 获取 Thumbprint
$thumbprint = $cert.Thumbprint
# 导入 WebAdministration 模块
Import-Module WebAdministration
# 检查是否已存在 HTTPS 绑定
$existingBinding = Get-WebBinding -Name "$siteName" -Protocol "https" -Port $port -HostHeader "$domain" -ErrorAction SilentlyContinue
if (!$existingBinding) {
# 添加新的 HTTPS 绑定
New-WebBinding -Name "$siteName" -Protocol "https" -Port $port -IPAddress "$ipaddr" -HostHeader "$domain"
}
# 获取绑定对象
$binding = Get-WebBinding -Name "$siteName" -Protocol "https" -Port $port -IPAddress "$ipaddr" -HostHeader "$domain"
# 绑定 SSL 证书
$binding.AddSslCertificate($thumbprint, "My")
# 删除目录下的证书文件
Remove-Item -Path "$pfxPath" -Force
`.trim();
});
setDeploy(newData);
}
break;
}
};
return (
<>
<div className="flex flex-col space-y-8">
<div>
<Label>{t("domain.deployment.form.file_format.label")}</Label>
<Select
value={data?.config?.format}
onValueChange={(value) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.format = value;
});
setDeploy(newData);
}}
>
<SelectTrigger>
<SelectValue placeholder={t("domain.deployment.form.file_format.placeholder")} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="pem">PEM</SelectItem>
<SelectItem value="pfx">PFX</SelectItem>
<SelectItem value="jks">JKS</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<div className="text-red-600 text-sm mt-1">{error?.format}</div>
</div>
<div>
<Label>{t("domain.deployment.form.file_cert_path.label")}</Label>
<Input
placeholder={t("domain.deployment.form.file_cert_path.label")}
className="w-full mt-1"
value={data?.config?.certPath}
onChange={(e) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.certPath = e.target.value?.trim();
});
setDeploy(newData);
}}
/>
<div className="text-red-600 text-sm mt-1">{error?.certPath}</div>
</div>
{data.config?.format === "pem" ? (
<div>
<Label>{t("domain.deployment.form.file_key_path.label")}</Label>
<Input
placeholder={t("domain.deployment.form.file_key_path.placeholder")}
className="w-full mt-1"
value={data?.config?.keyPath}
onChange={(e) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.keyPath = e.target.value?.trim();
});
setDeploy(newData);
}}
/>
<div className="text-red-600 text-sm mt-1">{error?.keyPath}</div>
</div>
) : (
<></>
)}
{data.config?.format === "pfx" ? (
<div>
<Label>{t("domain.deployment.form.file_pfx_password.label")}</Label>
<Input
placeholder={t("domain.deployment.form.file_pfx_password.placeholder")}
className="w-full mt-1"
value={data?.config?.pfxPassword}
onChange={(e) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.pfxPassword = e.target.value?.trim();
});
setDeploy(newData);
}}
/>
<div className="text-red-600 text-sm mt-1">{error?.pfxPassword}</div>
</div>
) : (
<></>
)}
{data.config?.format === "jks" ? (
<>
<div>
<Label>{t("domain.deployment.form.file_jks_alias.label")}</Label>
<Input
placeholder={t("domain.deployment.form.file_jks_alias.placeholder")}
className="w-full mt-1"
value={data?.config?.jksAlias}
onChange={(e) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.jksAlias = e.target.value?.trim();
});
setDeploy(newData);
}}
/>
<div className="text-red-600 text-sm mt-1">{error?.jksAlias}</div>
</div>
<div>
<Label>{t("domain.deployment.form.file_jks_keypass.label")}</Label>
<Input
placeholder={t("domain.deployment.form.file_jks_keypass.placeholder")}
className="w-full mt-1"
value={data?.config?.jksKeypass}
onChange={(e) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.jksKeypass = e.target.value?.trim();
});
setDeploy(newData);
}}
/>
<div className="text-red-600 text-sm mt-1">{error?.jksKeypass}</div>
</div>
<div>
<Label>{t("domain.deployment.form.file_jks_storepass.label")}</Label>
<Input
placeholder={t("domain.deployment.form.file_jks_storepass.placeholder")}
className="w-full mt-1"
value={data?.config?.jksStorepass}
onChange={(e) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.jksStorepass = e.target.value?.trim();
});
setDeploy(newData);
}}
/>
<div className="text-red-600 text-sm mt-1">{error?.jksStorepass}</div>
</div>
</>
) : (
<></>
)}
<div>
<Label>{t("domain.deployment.form.shell.label")}</Label>
<RadioGroup
className="flex mt-1"
value={data?.config?.shell}
onValueChange={(val) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.shell = val;
});
setDeploy(newData);
}}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="sh" id="shellOptionSh" />
<Label htmlFor="shellOptionSh">
<div className={cn("flex items-center space-x-2 border p-2 rounded cursor-pointer dark:border-stone-700", getOptionCls("sh"))}>
<div>POSIX Bash (Linux)</div>
</div>
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="cmd" id="shellOptionCmd" />
<Label htmlFor="shellOptionCmd">
<div className={cn("border p-2 rounded cursor-pointer dark:border-stone-700", getOptionCls("cmd"))}>
<div>CMD (Windows)</div>
</div>
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="powershell" id="shellOptionPowerShell" />
<Label htmlFor="shellOptionPowerShell">
<div className={cn("border p-2 rounded cursor-pointer dark:border-stone-700", getOptionCls("powershell"))}>
<div>PowerShell (Windows)</div>
</div>
</Label>
</div>
</RadioGroup>
<div className="text-red-600 text-sm mt-1">{error?.shell}</div>
</div>
<div>
<Label>{t("domain.deployment.form.shell_pre_command.label")}</Label>
<Textarea
className="mt-1"
value={data?.config?.preCommand}
placeholder={t("domain.deployment.form.shell_pre_command.placeholder")}
onChange={(e) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.preCommand = e.target.value;
});
setDeploy(newData);
}}
></Textarea>
<div className="text-red-600 text-sm mt-1">{error?.preCommand}</div>
</div>
<div>
<div className="flex items-center justify-between">
<Label>{t("domain.deployment.form.shell_command.label")}</Label>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<a className="text-xs text-blue-500 cursor-pointer">{t("domain.deployment.form.shell_preset_scripts.trigger")}</a>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => handleUsePresetScript("reload_nginx")}>
{t("domain.deployment.form.shell_preset_scripts.option.reload_nginx.label")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleUsePresetScript("binding_iis")}>
{t("domain.deployment.form.shell_preset_scripts.option.binding_iis.label")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<Textarea
className="mt-1"
value={data?.config?.command}
placeholder={t("domain.deployment.form.shell_command.placeholder")}
onChange={(e) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.command = e.target.value;
});
setDeploy(newData);
}}
></Textarea>
<div className="text-red-600 text-sm mt-1">{error?.command}</div>
</div>
</div>
</>
);
};
export default DeployToLocal;

View File

@@ -12,6 +12,17 @@ const DeployToQiniuCDN = () => {
const { deploy: data, setDeploy, error, setError } = useDeployEditContext();
useEffect(() => {
if (!data.id) {
setDeploy({
...data,
config: {
domain: "",
},
});
}
}, []);
useEffect(() => {
setError({});
}, []);

View File

@@ -1,29 +1,31 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { produce } from "immer";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { useDeployEditContext } from "./DeployEdit";
const DeployToSSH = () => {
const { t } = useTranslation();
const { setError } = useDeployEditContext();
useEffect(() => {
setError({});
}, []);
const { deploy: data, setDeploy } = useDeployEditContext();
const { deploy: data, setDeploy, error, setError } = useDeployEditContext();
useEffect(() => {
if (!data.id) {
setDeploy({
...data,
config: {
format: "pem",
certPath: "/etc/nginx/ssl/nginx.crt",
keyPath: "/etc/nginx/ssl/nginx.key",
pfxPassword: "",
jksAlias: "",
jksKeypass: "",
jksStorepass: "",
preCommand: "",
command: "sudo service nginx reload",
},
@@ -31,79 +33,287 @@ const DeployToSSH = () => {
}
}, []);
useEffect(() => {
setError({});
}, []);
const formSchema = z
.object({
format: z.union([z.literal("pem"), z.literal("pfx"), z.literal("jks")], {
message: t("domain.deployment.form.file_format.placeholder"),
}),
certPath: z
.string()
.min(1, t("domain.deployment.form.file_cert_path.placeholder"))
.max(255, t("common.errmsg.string_max", { max: 255 })),
keyPath: z
.string()
.min(0, t("domain.deployment.form.file_key_path.placeholder"))
.max(255, t("common.errmsg.string_max", { max: 255 })),
pfxPassword: z.string().optional(),
jksAlias: z.string().optional(),
jksKeypass: z.string().optional(),
jksStorepass: z.string().optional(),
preCommand: z.string().optional(),
command: z.string().optional(),
})
.refine((data) => (data.format === "pem" ? !!data.keyPath?.trim() : true), {
message: t("domain.deployment.form.file_key_path.placeholder"),
path: ["keyPath"],
})
.refine((data) => (data.format === "pfx" ? !!data.pfxPassword?.trim() : true), {
message: t("domain.deployment.form.file_pfx_password.placeholder"),
path: ["pfxPassword"],
})
.refine((data) => (data.format === "jks" ? !!data.jksAlias?.trim() : true), {
message: t("domain.deployment.form.file_jks_alias.placeholder"),
path: ["jksAlias"],
})
.refine((data) => (data.format === "jks" ? !!data.jksKeypass?.trim() : true), {
message: t("domain.deployment.form.file_jks_keypass.placeholder"),
path: ["jksKeypass"],
})
.refine((data) => (data.format === "jks" ? !!data.jksStorepass?.trim() : true), {
message: t("domain.deployment.form.file_jks_storepass.placeholder"),
path: ["jksStorepass"],
});
useEffect(() => {
const res = formSchema.safeParse(data.config);
if (!res.success) {
setError({
...error,
format: res.error.errors.find((e) => e.path[0] === "format")?.message,
certPath: res.error.errors.find((e) => e.path[0] === "certPath")?.message,
keyPath: res.error.errors.find((e) => e.path[0] === "keyPath")?.message,
pfxPassword: res.error.errors.find((e) => e.path[0] === "pfxPassword")?.message,
jksAlias: res.error.errors.find((e) => e.path[0] === "jksAlias")?.message,
jksKeypass: res.error.errors.find((e) => e.path[0] === "jksKeypass")?.message,
jksStorepass: res.error.errors.find((e) => e.path[0] === "jksStorepass")?.message,
preCommand: res.error.errors.find((e) => e.path[0] === "preCommand")?.message,
command: res.error.errors.find((e) => e.path[0] === "command")?.message,
});
} else {
setError({
...error,
format: undefined,
certPath: undefined,
keyPath: undefined,
pfxPassword: undefined,
jksAlias: undefined,
jksKeypass: undefined,
jksStorepass: undefined,
preCommand: undefined,
command: undefined,
});
}
}, [data]);
useEffect(() => {
if (data.config?.format === "pem") {
if (/(.pfx|.jks)$/.test(data.config.certPath)) {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.certPath = data.config!.certPath.replace(/(.pfx|.jks)$/, ".crt");
});
setDeploy(newData);
}
} else if (data.config?.format === "pfx") {
if (/(.crt|.jks)$/.test(data.config.certPath)) {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.certPath = data.config!.certPath.replace(/(.crt|.jks)$/, ".pfx");
});
setDeploy(newData);
}
} else if (data.config?.format === "jks") {
if (/(.crt|.pfx)$/.test(data.config.certPath)) {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.certPath = data.config!.certPath.replace(/(.crt|.pfx)$/, ".jks");
});
setDeploy(newData);
}
}
}, [data.config?.format]);
return (
<>
<div className="flex flex-col space-y-8">
<div>
<Label>{t("domain.deployment.form.ssh_cert_path.label")}</Label>
<Label>{t("domain.deployment.form.file_format.label")}</Label>
<Select
value={data?.config?.format}
onValueChange={(value) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.format = value;
});
setDeploy(newData);
}}
>
<SelectTrigger>
<SelectValue placeholder={t("domain.deployment.form.file_format.placeholder")} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="pem">PEM</SelectItem>
<SelectItem value="pfx">PFX</SelectItem>
<SelectItem value="jks">JKS</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<div className="text-red-600 text-sm mt-1">{error?.format}</div>
</div>
<div>
<Label>{t("domain.deployment.form.file_cert_path.label")}</Label>
<Input
placeholder={t("domain.deployment.form.ssh_cert_path.label")}
placeholder={t("domain.deployment.form.file_cert_path.label")}
className="w-full mt-1"
value={data?.config?.certPath}
onChange={(e) => {
const newData = produce(data, (draft) => {
if (!draft.config) {
draft.config = {};
}
draft.config.certPath = e.target.value;
draft.config ??= {};
draft.config.certPath = e.target.value?.trim();
});
setDeploy(newData);
}}
/>
<div className="text-red-600 text-sm mt-1">{error?.certPath}</div>
</div>
<div>
<Label>{t("domain.deployment.form.ssh_key_path.label")}</Label>
<Input
placeholder={t("domain.deployment.form.ssh_key_path.placeholder")}
className="w-full mt-1"
value={data?.config?.keyPath}
onChange={(e) => {
const newData = produce(data, (draft) => {
if (!draft.config) {
draft.config = {};
}
draft.config.keyPath = e.target.value;
});
setDeploy(newData);
}}
/>
</div>
{data.config?.format === "pem" ? (
<div>
<Label>{t("domain.deployment.form.file_key_path.label")}</Label>
<Input
placeholder={t("domain.deployment.form.file_key_path.placeholder")}
className="w-full mt-1"
value={data?.config?.keyPath}
onChange={(e) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.keyPath = e.target.value?.trim();
});
setDeploy(newData);
}}
/>
<div className="text-red-600 text-sm mt-1">{error?.keyPath}</div>
</div>
) : (
<></>
)}
{data.config?.format === "pfx" ? (
<div>
<Label>{t("domain.deployment.form.file_pfx_password.label")}</Label>
<Input
placeholder={t("domain.deployment.form.file_pfx_password.placeholder")}
className="w-full mt-1"
value={data?.config?.pfxPassword}
onChange={(e) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.pfxPassword = e.target.value?.trim();
});
setDeploy(newData);
}}
/>
<div className="text-red-600 text-sm mt-1">{error?.pfxPassword}</div>
</div>
) : (
<></>
)}
{data.config?.format === "jks" ? (
<>
<div>
<Label>{t("domain.deployment.form.file_jks_alias.label")}</Label>
<Input
placeholder={t("domain.deployment.form.file_jks_alias.placeholder")}
className="w-full mt-1"
value={data?.config?.jksAlias}
onChange={(e) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.jksAlias = e.target.value?.trim();
});
setDeploy(newData);
}}
/>
<div className="text-red-600 text-sm mt-1">{error?.jksAlias}</div>
</div>
<div>
<Label>{t("domain.deployment.form.file_jks_keypass.label")}</Label>
<Input
placeholder={t("domain.deployment.form.file_jks_keypass.placeholder")}
className="w-full mt-1"
value={data?.config?.jksKeypass}
onChange={(e) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.jksKeypass = e.target.value?.trim();
});
setDeploy(newData);
}}
/>
<div className="text-red-600 text-sm mt-1">{error?.jksKeypass}</div>
</div>
<div>
<Label>{t("domain.deployment.form.file_jks_storepass.label")}</Label>
<Input
placeholder={t("domain.deployment.form.file_jks_storepass.placeholder")}
className="w-full mt-1"
value={data?.config?.jksStorepass}
onChange={(e) => {
const newData = produce(data, (draft) => {
draft.config ??= {};
draft.config.jksStorepass = e.target.value?.trim();
});
setDeploy(newData);
}}
/>
<div className="text-red-600 text-sm mt-1">{error?.jksStorepass}</div>
</div>
</>
) : (
<></>
)}
<div>
<Label>{t("domain.deployment.form.ssh_pre_command.label")}</Label>
<Label>{t("domain.deployment.form.shell_pre_command.label")}</Label>
<Textarea
className="mt-1"
value={data?.config?.preCommand}
placeholder={t("domain.deployment.form.ssh_pre_command.placeholder")}
placeholder={t("domain.deployment.form.shell_pre_command.placeholder")}
onChange={(e) => {
const newData = produce(data, (draft) => {
if (!draft.config) {
draft.config = {};
}
draft.config ??= {};
draft.config.preCommand = e.target.value;
});
setDeploy(newData);
}}
></Textarea>
<div className="text-red-600 text-sm mt-1">{error?.preCommand}</div>
</div>
<div>
<Label>{t("domain.deployment.form.ssh_command.label")}</Label>
<Label>{t("domain.deployment.form.shell_command.label")}</Label>
<Textarea
className="mt-1"
value={data?.config?.command}
placeholder={t("domain.deployment.form.ssh_command.placeholder")}
placeholder={t("domain.deployment.form.shell_command.placeholder")}
onChange={(e) => {
const newData = produce(data, (draft) => {
if (!draft.config) {
draft.config = {};
}
draft.config ??= {};
draft.config.command = e.target.value;
});
setDeploy(newData);
}}
></Textarea>
<div className="text-red-600 text-sm mt-1">{error?.command}</div>
</div>
</div>
</>

View File

@@ -76,7 +76,6 @@ const DeployToTencentCLB = () => {
}
}, []);
useEffect(() => {
if (!data.id) {
setDeploy({
@@ -92,7 +91,7 @@ const DeployToTencentCLB = () => {
}, []);
const regionSchema = z.string().regex(/^ap-[a-z]+$/, {
message: t("domain.deployment.form.clb_region.placeholder"),
message: t("domain.deployment.form.tencent_clb_region.placeholder"),
});
const domainSchema = z.string().regex(/^$|^(?:\*\.)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/, {
@@ -100,19 +99,19 @@ const DeployToTencentCLB = () => {
});
const clbIdSchema = z.string().regex(/^lb-[a-zA-Z0-9]{8}$/, {
message: t("domain.deployment.form.clb_id.placeholder"),
message: t("domain.deployment.form.tencent_clb_id.placeholder"),
});
const lsnIdSchema = z.string().regex(/^lbl-.{8}$/, {
message: t("domain.deployment.form.clb_listener.placeholder"),
message: t("domain.deployment.form.tencent_clb_listener.placeholder"),
});
return (
<div className="flex flex-col space-y-8">
<div>
<Label>{t("domain.deployment.form.clb_region.label")}</Label>
<Label>{t("domain.deployment.form.tencent_clb_region.label")}</Label>
<Input
placeholder={t("domain.deployment.form.clb_region.placeholder")}
placeholder={t("domain.deployment.form.tencent_clb_region.placeholder")}
className="w-full mt-1"
value={data?.config?.region}
onChange={(e) => {
@@ -144,9 +143,9 @@ const DeployToTencentCLB = () => {
</div>
<div>
<Label>{t("domain.deployment.form.clb_id.label")}</Label>
<Label>{t("domain.deployment.form.tencent_clb_id.label")}</Label>
<Input
placeholder={t("domain.deployment.form.clb_id.placeholder")}
placeholder={t("domain.deployment.form.tencent_clb_id.placeholder")}
className="w-full mt-1"
value={data?.config?.clbId}
onChange={(e) => {
@@ -178,9 +177,9 @@ const DeployToTencentCLB = () => {
</div>
<div>
<Label>{t("domain.deployment.form.clb_listener.label")}</Label>
<Label>{t("domain.deployment.form.tencent_clb_listener.label")}</Label>
<Input
placeholder={t("domain.deployment.form.clb_listener.placeholder")}
placeholder={t("domain.deployment.form.tencent_clb_listener.placeholder")}
className="w-full mt-1"
value={data?.config?.lsnId}
onChange={(e) => {
@@ -212,9 +211,9 @@ const DeployToTencentCLB = () => {
</div>
<div>
<Label>{t("domain.deployment.form.clb_domain.label")}</Label>
<Label>{t("domain.deployment.form.tencent_clb_domain.label")}</Label>
<Input
placeholder={t("domain.deployment.form.clb_domain.placeholder")}
placeholder={t("domain.deployment.form.tencent_clb_domain.placeholder")}
className="w-full mt-1"
value={data?.config?.domain}
onChange={(e) => {

View File

@@ -89,9 +89,9 @@ const DeployToTencentCOS = () => {
return (
<div className="flex flex-col space-y-8">
<div>
<Label>{t("domain.deployment.form.cos_region.label")}</Label>
<Label>{t("domain.deployment.form.tencent_cos_region.label")}</Label>
<Input
placeholder={t("domain.deployment.form.cos_region.placeholder")}
placeholder={t("domain.deployment.form.tencent_cos_region.placeholder")}
className="w-full mt-1"
value={data?.config?.region}
onChange={(e) => {
@@ -123,9 +123,9 @@ const DeployToTencentCOS = () => {
</div>
<div>
<Label>{t("domain.deployment.form.cos_bucket.label")}</Label>
<Label>{t("domain.deployment.form.tencent_cos_bucket.label")}</Label>
<Input
placeholder={t("domain.deployment.form.cos_bucket.placeholder")}
placeholder={t("domain.deployment.form.tencent_cos_bucket.placeholder")}
className="w-full mt-1"
value={data?.config?.bucket}
onChange={(e) => {

View File

@@ -0,0 +1,131 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { produce } from "immer";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useDeployEditContext } from "./DeployEdit";
const DeployToTencentTEO = () => {
const { t } = useTranslation();
const { deploy: data, setDeploy, error, setError } = useDeployEditContext();
useEffect(() => {
setError({});
}, []);
useEffect(() => {
const resp = domainSchema.safeParse(data.config?.domain);
if (!resp.success) {
setError({
...error,
domain: JSON.parse(resp.error.message)[0].message,
});
} else {
setError({
...error,
domain: "",
});
}
}, [data]);
useEffect(() => {
const resp = zoneIdSchema.safeParse(data.config?.zoneId);
if (!resp.success) {
setError({
...error,
zoneId: JSON.parse(resp.error.message)[0].message,
});
} else {
setError({
...error,
zoneId: "",
});
}
}, [data]);
const domainSchema = z.string().regex(/^(?:\*\.)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/, {
message: t("common.errmsg.domain_invalid"),
});
const zoneIdSchema = z.string().regex(/^zone-[0-9a-zA-Z]{9}$/, {
message: t("common.errmsg.zoneid_invalid"),
});
return (
<div className="flex flex-col space-y-8">
<div>
<Label>{t("domain.deployment.form.tencent_teo_zone_id.label")}</Label>
<Input
placeholder={t("domain.deployment.form.tencent_teo_zone_id.placeholder")}
className="w-full mt-1"
value={data?.config?.zoneId}
onChange={(e) => {
const temp = e.target.value;
const resp = zoneIdSchema.safeParse(temp);
if (!resp.success) {
setError({
...error,
zoneId: JSON.parse(resp.error.message)[0].message,
});
} else {
setError({
...error,
zoneId: "",
});
}
const newData = produce(data, (draft) => {
if (!draft.config) {
draft.config = {};
}
draft.config.zoneId = temp;
});
setDeploy(newData);
}}
/>
<div className="text-red-600 text-sm mt-1">{error?.zoneId}</div>
</div>
<div>
<Label>{t("domain.deployment.form.tencent_teo_domain.label")}</Label>
<Textarea
placeholder={t("domain.deployment.form.tencent_teo_domain.placeholder")}
className="w-full mt-1"
value={data?.config?.domain}
onChange={(e) => {
const temp = e.target.value;
const resp = domainSchema.safeParse(temp);
if (!resp.success) {
setError({
...error,
domain: JSON.parse(resp.error.message)[0].message,
});
} else {
setError({
...error,
domain: "",
});
}
const newData = produce(data, (draft) => {
if (!draft.config) {
draft.config = {};
}
draft.config.domain = temp;
});
setDeploy(newData);
}}
/>
<div className="text-red-600 text-sm mt-1">{error?.domain}</div>
</div>
</div>
);
};
export default DeployToTencentTEO;

View File

@@ -0,0 +1,319 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useToast } from "@/components/ui/use-toast";
import { getErrMessage } from "@/lib/error";
import { NotifyChannelMail, NotifyChannels } from "@/domain/settings";
import { useNotifyContext } from "@/providers/notify";
import { update } from "@/repository/settings";
import Show from "@/components/Show";
import { notifyTest } from "@/api/notify";
type MailSetting = {
id: string;
name: string;
data: NotifyChannelMail;
};
const Mail = () => {
const { config, setChannels } = useNotifyContext();
const { t } = useTranslation();
const [changed, setChanged] = useState<boolean>(false);
const [mail, setmail] = useState<MailSetting>({
id: config.id ?? "",
name: "notifyChannels",
data: {
senderAddress: "",
receiverAddresses: "",
smtpHostAddr: "",
smtpHostPort: "25",
username: "",
password: "",
enabled: false,
},
});
const [originMail, setoriginMail] = useState<MailSetting>({
id: config.id ?? "",
name: "notifyChannels",
data: {
senderAddress: "",
receiverAddresses: "",
smtpHostAddr: "",
smtpHostPort: "25",
username: "",
password: "",
enabled: false,
},
});
useEffect(() => {
setChanged(false);
}, [config]);
useEffect(() => {
const data = getDetailMail();
setoriginMail({
id: config.id ?? "",
name: "mail",
data,
});
}, [config]);
useEffect(() => {
const data = getDetailMail();
setmail({
id: config.id ?? "",
name: "mail",
data,
});
}, [config]);
const { toast } = useToast();
const getDetailMail = () => {
const df: NotifyChannelMail = {
senderAddress: "",
receiverAddresses: "",
smtpHostAddr: "",
smtpHostPort: "25",
username: "",
password: "",
enabled: false,
};
if (!config.content) {
return df;
}
const chanels = config.content as NotifyChannels;
if (!chanels.mail) {
return df;
}
return chanels.mail as NotifyChannelMail;
};
const checkChanged = (data: NotifyChannelMail) => {
if (data.senderAddress !== originMail.data.senderAddress || data.receiverAddresses !== originMail.data.receiverAddresses || data.smtpHostAddr !== originMail.data.smtpHostAddr || data.smtpHostPort !== originMail.data.smtpHostPort || data.username !== originMail.data.username || data.password !== originMail.data.password) {
setChanged(true);
} else {
setChanged(false);
}
};
const handleSaveClick = async () => {
try {
const resp = await update({
...config,
name: "notifyChannels",
content: {
...config.content,
mail: {
...mail.data,
},
},
});
setChannels(resp);
toast({
title: t("common.save.succeeded.message"),
description: t("settings.notification.config.saved.message"),
});
} catch (e) {
const msg = getErrMessage(e);
toast({
title: t("common.save.failed.message"),
description: `${t("settings.notification.config.failed.message")}: ${msg}`,
variant: "destructive",
});
}
};
const handlePushTestClick = async () => {
try {
await notifyTest("mail");
toast({
title: t("settings.notification.config.push.test.message.success.message"),
description: t("settings.notification.config.push.test.message.success.message"),
});
} catch (e) {
const msg = getErrMessage(e);
toast({
title: t("settings.notification.config.push.test.message.failed.message"),
description: `${t("settings.notification.config.push.test.message.failed.message")}: ${msg}`,
variant: "destructive",
});
}
};
const handleSwitchChange = async () => {
const newData = {
...mail,
data: {
...mail.data,
enabled: !mail.data.enabled,
},
};
setmail(newData);
try {
const resp = await update({
...config,
name: "notifyChannels",
content: {
...config.content,
mail: {
...newData.data,
},
},
});
setChannels(resp);
} catch (e) {
const msg = getErrMessage(e);
toast({
title: t("common.save.failed.message"),
description: `${t("settings.notification.config.failed.message")}: ${msg}`,
variant: "destructive",
});
}
};
return (
<div>
<Input
placeholder={t("settings.notification.mail.sender_address.placeholder")}
value={mail.data.senderAddress}
onChange={(e) => {
const newData = {
...mail,
data: {
...mail.data,
senderAddress: e.target.value,
},
};
checkChanged(newData.data);
setmail(newData);
}}
/>
<Input
placeholder={t("settings.notification.mail.receiver_address.placeholder")}
className="mt-2"
value={mail.data.receiverAddresses}
onChange={(e) => {
const newData = {
...mail,
data: {
...mail.data,
receiverAddresses: e.target.value,
},
};
checkChanged(newData.data);
setmail(newData);
}}
/>
<Input
placeholder={t("settings.notification.mail.smtp_host.placeholder")}
className="mt-2"
value={mail.data.smtpHostAddr}
onChange={(e) => {
const newData = {
...mail,
data: {
...mail.data,
smtpHostAddr: e.target.value,
},
};
checkChanged(newData.data);
setmail(newData);
}}
/>
<Input
placeholder={t("settings.notification.mail.smtp_port.placeholder")}
className="mt-2"
value={mail.data.smtpHostPort}
onChange={(e) => {
const newData = {
...mail,
data: {
...mail.data,
smtpHostPort: e.target.value,
},
};
checkChanged(newData.data);
setmail(newData);
}}
/>
<Input
placeholder={t("settings.notification.mail.username.placeholder")}
className="mt-2"
value={mail.data.username}
onChange={(e) => {
const newData = {
...mail,
data: {
...mail.data,
username: e.target.value,
},
};
checkChanged(newData.data);
setmail(newData);
}}
/>
<Input
placeholder={t("settings.notification.mail.password.placeholder")}
className="mt-2"
value={mail.data.password}
onChange={(e) => {
const newData = {
...mail,
data: {
...mail.data,
password: e.target.value,
},
};
checkChanged(newData.data);
setmail(newData);
}}
/>
<div className="flex items-center space-x-1 mt-2">
<Switch id="airplane-mode" checked={mail.data.enabled} onCheckedChange={handleSwitchChange} />
<Label htmlFor="airplane-mode">{t("settings.notification.config.enable")}</Label>
</div>
<div className="flex justify-end mt-2">
<Show when={changed}>
<Button
onClick={() => {
handleSaveClick();
}}
>
{t("common.save")}
</Button>
</Show>
<Show when={!changed && mail.id != ""}>
<Button
variant="secondary"
onClick={() => {
handlePushTestClick();
}}
>
{t("settings.notification.config.push.test.message")}
</Button>
</Show>
</div>
</div>
);
};
export default Mail;

View File

@@ -0,0 +1,236 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useToast } from "@/components/ui/use-toast";
import { getErrMessage } from "@/lib/error";
import { isValidURL } from "@/lib/url";
import { NotifyChannels, NotifyChannelServerChan } from "@/domain/settings";
import { update } from "@/repository/settings";
import { useNotifyContext } from "@/providers/notify";
import { notifyTest } from "@/api/notify";
import Show from "@/components/Show";
type ServerChanSetting = {
id: string;
name: string;
data: NotifyChannelServerChan;
};
const ServerChan = () => {
const { config, setChannels } = useNotifyContext();
const { t } = useTranslation();
const [changed, setChanged] = useState<boolean>(false);
const [serverchan, setServerChan] = useState<ServerChanSetting>({
id: config.id ?? "",
name: "notifyChannels",
data: {
url: "",
enabled: false,
},
});
const [originServerChan, setOriginServerChan] = useState<ServerChanSetting>({
id: config.id ?? "",
name: "notifyChannels",
data: {
url: "",
enabled: false,
},
});
useEffect(() => {
setChanged(false);
}, [config]);
useEffect(() => {
const data = getDetailServerChan();
setOriginServerChan({
id: config.id ?? "",
name: "serverchan",
data,
});
}, [config]);
useEffect(() => {
const data = getDetailServerChan();
setServerChan({
id: config.id ?? "",
name: "serverchan",
data,
});
}, [config]);
const { toast } = useToast();
const checkChanged = (data: NotifyChannelServerChan) => {
if (data.url !== originServerChan.data.url) {
setChanged(true);
} else {
setChanged(false);
}
};
const getDetailServerChan = () => {
const df: NotifyChannelServerChan = {
url: "",
enabled: false,
};
if (!config.content) {
return df;
}
const chanels = config.content as NotifyChannels;
if (!chanels.serverchan) {
return df;
}
return chanels.serverchan as NotifyChannelServerChan;
};
const handleSaveClick = async () => {
try {
serverchan.data.url = serverchan.data.url.trim();
if (!isValidURL(serverchan.data.url)) {
toast({
title: t("common.save.failed.message"),
description: t("settings.notification.url.errmsg.invalid"),
variant: "destructive",
});
return;
}
const resp = await update({
...config,
name: "notifyChannels",
content: {
...config.content,
serverchan: {
...serverchan.data,
},
},
});
setChannels(resp);
toast({
title: t("common.save.succeeded.message"),
description: t("settings.notification.config.saved.message"),
});
} catch (e) {
const msg = getErrMessage(e);
toast({
title: t("common.save.failed.message"),
description: `${t("settings.notification.config.failed.message")}: ${msg}`,
variant: "destructive",
});
}
};
const handlePushTestClick = async () => {
try {
await notifyTest("serverchan");
toast({
title: t("settings.notification.config.push.test.message.success.message"),
description: t("settings.notification.config.push.test.message.success.message"),
});
} catch (e) {
const msg = getErrMessage(e);
toast({
title: t("settings.notification.config.push.test.message.failed.message"),
description: `${t("settings.notification.config.push.test.message.failed.message")}: ${msg}`,
variant: "destructive",
});
}
};
const handleSwitchChange = async () => {
const newData = {
...serverchan,
data: {
...serverchan.data,
enabled: !serverchan.data.enabled,
},
};
setServerChan(newData);
try {
const resp = await update({
...config,
name: "notifyChannels",
content: {
...config.content,
serverchan: {
...newData.data,
},
},
});
setChannels(resp);
} catch (e) {
const msg = getErrMessage(e);
toast({
title: t("common.save.failed.message"),
description: `${t("settings.notification.config.failed.message")}: ${msg}`,
variant: "destructive",
});
}
};
return (
<div>
<Input
placeholder={t("settings.notification.serverchan.url.placeholder")}
value={serverchan.data.url}
onChange={(e) => {
const newData = {
...serverchan,
data: {
...serverchan.data,
url: e.target.value,
},
};
checkChanged(newData.data);
setServerChan(newData);
}}
/>
<div className="flex items-center space-x-1 mt-2">
<Switch id="airplane-mode" checked={serverchan.data.enabled} onCheckedChange={handleSwitchChange} />
<Label htmlFor="airplane-mode">{t("settings.notification.config.enable")}</Label>
</div>
<div className="flex justify-end mt-2">
<Show when={changed}>
<Button
onClick={() => {
handleSaveClick();
}}
>
{t("common.save")}
</Button>
</Show>
<Show when={!changed && serverchan.id != ""}>
<Button
variant="secondary"
onClick={() => {
handlePushTestClick();
}}
>
{t("settings.notification.config.push.test.message")}
</Button>
</Show>
</div>
</div>
);
};
export default ServerChan;

View File

@@ -0,0 +1,105 @@
import * as React from "react";
import { type DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import { cn } from "@/lib/utils";
import { Dialog, DialogContent } from "@/components/ui/dialog";
const Command = React.forwardRef<React.ElementRef<typeof CommandPrimitive>, React.ComponentPropsWithoutRef<typeof CommandPrimitive>>(
({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn("flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground", className)}
{...props}
/>
)
);
Command.displayName = CommandPrimitive.displayName;
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<React.ElementRef<typeof CommandPrimitive.Input>, React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>>(
({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
);
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<React.ElementRef<typeof CommandPrimitive.List>, React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>>(
({ className, ...props }, ref) => <CommandPrimitive.List ref={ref} className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)} {...props} />
);
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<React.ElementRef<typeof CommandPrimitive.Empty>, React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>>(
(props, ref) => <CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />
);
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<React.ElementRef<typeof CommandPrimitive.Group>, React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>>(
({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
)
);
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => <CommandPrimitive.Separator ref={ref} className={cn("-mx-1 h-px bg-border", className)} {...props} />);
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<React.ElementRef<typeof CommandPrimitive.Item>, React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>>(
({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className
)}
{...props}
/>
)
);
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props} />;
};
CommandShortcut.displayName = "CommandShortcut";
export { Command, CommandDialog, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandShortcut, CommandSeparator };

View File

@@ -1,75 +1,122 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
"use client"
import { cn } from "@/lib/utils";
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
const Dialog = DialogPrimitive.Root;
import { cn } from "@/lib/utils"
const DialogTrigger = DialogPrimitive.Trigger;
const Dialog = DialogPrimitive.Root
const DialogPortal = DialogPrimitive.Portal;
const DialogTrigger = DialogPrimitive.Trigger
const DialogClose = DialogPrimitive.Close;
const DialogPortal = DialogPrimitive.Portal
const DialogOverlay = React.forwardRef<React.ElementRef<typeof DialogPrimitive.Overlay>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>>(
({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
)
);
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogContent = React.forwardRef<React.ElementRef<typeof DialogPrimitive.Content>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>>(
({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
)
);
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<React.ElementRef<typeof DialogPrimitive.Title>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>>(
({ className, ...props }, ref) => (
<DialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold leading-none tracking-tight", className)} {...props} />
)
);
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => <DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />);
DialogDescription.displayName = DialogPrimitive.Description.displayName;
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export { Dialog, DialogPortal, DialogOverlay, DialogClose, DialogTrigger, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription };
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,29 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }

View File

@@ -7,25 +7,26 @@ type AccessProvider = {
name: string;
icon: string;
usage: AccessUsages;
searchContent: string;
};
export const accessProvidersMap: Map<AccessProvider["type"], AccessProvider> = new Map(
[
["aliyun", "common.provider.aliyun", "/imgs/providers/aliyun.svg", "all"],
["tencent", "common.provider.tencent", "/imgs/providers/tencent.svg", "all"],
["huaweicloud", "common.provider.huaweicloud", "/imgs/providers/huaweicloud.svg", "all"],
["qiniu", "common.provider.qiniu", "/imgs/providers/qiniu.svg", "deploy"],
["aws", "common.provider.aws", "/imgs/providers/aws.svg", "apply"],
["cloudflare", "common.provider.cloudflare", "/imgs/providers/cloudflare.svg", "apply"],
["namesilo", "common.provider.namesilo", "/imgs/providers/namesilo.svg", "apply"],
["godaddy", "common.provider.godaddy", "/imgs/providers/godaddy.svg", "apply"],
["pdns", "common.provider.pdns", "/imgs/providers/pdns.svg", "apply"],
["httpreq", "common.provider.httpreq", "/imgs/providers/httpreq.svg", "apply"],
["local", "common.provider.local", "/imgs/providers/local.svg", "deploy"],
["ssh", "common.provider.ssh", "/imgs/providers/ssh.svg", "deploy"],
["webhook", "common.provider.webhook", "/imgs/providers/webhook.svg", "deploy"],
["k8s", "common.provider.kubernetes", "/imgs/providers/k8s.svg", "deploy"],
].map(([type, name, icon, usage]) => [type, { type, name, icon, usage: usage as AccessUsages }])
["aliyun", "common.provider.aliyun", "/imgs/providers/aliyun.svg", "all", "阿里云:alibaba cloud"],
["tencent", "common.provider.tencent", "/imgs/providers/tencent.svg", "all", "腾讯云:tencent cloud"],
["huaweicloud", "common.provider.huaweicloud", "/imgs/providers/huaweicloud.svg", "all", "华为云:huawei cloud"],
["qiniu", "common.provider.qiniu", "/imgs/providers/qiniu.svg", "deploy", "七牛:qiniu"],
["aws", "common.provider.aws", "/imgs/providers/aws.svg", "apply", "亚马逊:amazon:aws"],
["cloudflare", "common.provider.cloudflare", "/imgs/providers/cloudflare.svg", "apply", "cloudflare:cf:cloud flare"],
["namesilo", "common.provider.namesilo", "/imgs/providers/namesilo.svg", "apply", "namesilo"],
["godaddy", "common.provider.godaddy", "/imgs/providers/godaddy.svg", "apply", "godaddy"],
["pdns", "common.provider.pdns", "/imgs/providers/pdns.svg", "apply", "powerdns:pdns"],
["httpreq", "common.provider.httpreq", "/imgs/providers/httpreq.svg", "apply", "httpreq"],
["local", "common.provider.local", "/imgs/providers/local.svg", "deploy", "local:bendi:本地"],
["ssh", "common.provider.ssh", "/imgs/providers/ssh.svg", "deploy", "ssh"],
["webhook", "common.provider.webhook", "/imgs/providers/webhook.svg", "deploy", "webhook"],
["k8s", "common.provider.kubernetes", "/imgs/providers/k8s.svg", "deploy", "k8s:kubernetes"],
].map(([type, name, icon, usage, searchContent]) => [type, { type, name, icon, usage: usage as AccessUsages, searchContent: searchContent }])
);
export const accessTypeFormSchema = z.union(

View File

@@ -65,6 +65,7 @@ export type Statistic = {
type DeployTarget = {
type: string;
provider: string;
name: string;
icon: string;
};
@@ -74,14 +75,20 @@ export const deployTargetsMap: Map<DeployTarget["type"], DeployTarget> = new Map
["aliyun-oss", "common.provider.aliyun.oss", "/imgs/providers/aliyun.svg"],
["aliyun-cdn", "common.provider.aliyun.cdn", "/imgs/providers/aliyun.svg"],
["aliyun-dcdn", "common.provider.aliyun.dcdn", "/imgs/providers/aliyun.svg"],
["aliyun-clb", "common.provider.aliyun.clb", "/imgs/providers/aliyun.svg"],
["aliyun-alb", "common.provider.aliyun.alb", "/imgs/providers/aliyun.svg"],
["aliyun-nlb", "common.provider.aliyun.nlb", "/imgs/providers/aliyun.svg"],
["tencent-cdn", "common.provider.tencent.cdn", "/imgs/providers/tencent.svg"],
["tencent-ecdn", "common.provider.tencent.ecdn", "/imgs/providers/tencent.svg"],
["tencent-clb", "common.provider.tencent.clb", "/imgs/providers/tencent.svg"],
["tencent-cos", "common.provider.tencent.cos", "/imgs/providers/tencent.svg"],
["tencent-teo", "common.provider.tencent.teo", "/imgs/providers/tencent.svg"],
["huaweicloud-cdn", "common.provider.huaweicloud.cdn", "/imgs/providers/huaweicloud.svg"],
["huaweicloud-elb", "common.provider.huaweicloud.elb", "/imgs/providers/huaweicloud.svg"],
["qiniu-cdn", "common.provider.qiniu.cdn", "/imgs/providers/qiniu.svg"],
["local", "common.provider.local", "/imgs/providers/local.svg"],
["ssh", "common.provider.ssh", "/imgs/providers/ssh.svg"],
["webhook", "common.provider.webhook", "/imgs/providers/webhook.svg"],
["k8s-secret", "common.provider.kubernetes.secret", "/imgs/providers/k8s.svg"],
].map(([type, name, icon]) => [type, { type, name, icon }])
].map(([type, name, icon]) => [type, { type, provider: type.split("-")[0], name, icon }])
);

View File

@@ -22,9 +22,11 @@ export type NotifyChannels = {
lark?: NotifyChannel;
telegram?: NotifyChannel;
webhook?: NotifyChannel;
serverchan?: NotifyChannel;
mail?: NotifyChannelMail;
};
export type NotifyChannel = NotifyChannelDingTalk | NotifyChannelLark | NotifyChannelTelegram | NotifyChannelWebhook;
export type NotifyChannel = NotifyChannelDingTalk | NotifyChannelLark | NotifyChannelTelegram | NotifyChannelWebhook | NotifyChannelServerChan | NotifyChannelMail;
export type NotifyChannelDingTalk = {
accessToken: string;
@@ -48,6 +50,21 @@ export type NotifyChannelWebhook = {
enabled: boolean;
};
export type NotifyChannelServerChan = {
url: string;
enabled: boolean;
};
export type NotifyChannelMail = {
senderAddress: string;
receiverAddresses: string;
smtpHostAddr: string;
smtpHostPort: string;
username:string;
password:string;
enabled: boolean;
};
export const defaultNotifyTemplate: NotifyTemplate = {
title: "您有 {COUNT} 张证书即将过期",
content: "有 {COUNT} 张证书即将过期,域名分别为 {DOMAINS},请保持关注!",

View File

@@ -1 +1 @@
export const version = "Certimate v0.2.8";
export const version = "Certimate v0.2.11";

View File

@@ -12,6 +12,8 @@
"access.authorization.form.type.label": "Provider",
"access.authorization.form.type.placeholder": "Please select a provider",
"access.authorization.form.type.search.placeholder": "Search provider ...",
"access.authorization.form.type.search.notfound": "Provider not found",
"access.authorization.form.type.list": "Authorization List",
"access.authorization.form.name.label": "Name",
"access.authorization.form.name.placeholder": "Please enter authorization name",
@@ -69,9 +71,9 @@
"access.authorization.form.ssh_key_passphrase.placeholder": "Please enter Key Passphrase",
"access.authorization.form.webhook_url.label": "Webhook URL",
"access.authorization.form.webhook_url.placeholder": "Please enter Webhook URL",
"access.authorization.form.k8s_kubeconfig.label": "KubeConfig",
"access.authorization.form.k8s_kubeconfig.label": "KubeConfig (Null will use pod's ServiceAccount)",
"access.authorization.form.k8s_kubeconfig.placeholder": "Please enter KubeConfig",
"access.authorization.form.k8s_kubeconfig_file.placeholder": "Please select file",
"access.authorization.form.k8s_kubeconfig_file.placeholder": "Please select file (Null will use pod's ServiceAccount)",
"access.group.tab": "Authorization Group",
@@ -88,3 +90,4 @@
"access.group.domains": "All Authorizations",
"access.group.domains.nodata": "Please add a domain to start deploying the certificate."
}

View File

@@ -51,19 +51,26 @@
"common.errmsg.host_invalid": "Please enter the correct domain name or IP",
"common.errmsg.ip_invalid": "Please enter IP",
"common.errmsg.url_invalid": "Please enter a valid URL",
"common.errmsg.zoneid_invalid": "Please enter Zone ID",
"common.provider.aliyun": "Alibaba Cloud",
"common.provider.aliyun.oss": "Alibaba Cloud - OSS",
"common.provider.aliyun.cdn": "Alibaba Cloud - CDN",
"common.provider.aliyun.dcdn": "Alibaba Cloud - DCDN",
"common.provider.tencent": "Tencent",
"common.provider.tencent.cdn": "Tencent - CDN",
"common.provider.tencent.clb": "Tencent - CLB",
"common.provider.tencent.cos": "Tencent - COS",
"common.provider.aliyun.clb": "Alibaba Cloud - CLB",
"common.provider.aliyun.alb": "Alibaba Cloud - ALB",
"common.provider.aliyun.nlb": "Alibaba Cloud - NLB",
"common.provider.tencent": "Tencent Cloud",
"common.provider.tencent.cdn": "Tencent Cloud - CDN",
"common.provider.tencent.ecdn": "Tencent Cloud - ECDN",
"common.provider.tencent.clb": "Tencent Cloud - CLB",
"common.provider.tencent.cos": "Tencent Cloud - COS",
"common.provider.tencent.teo": "Tencent Cloud - TEO",
"common.provider.huaweicloud": "Huawei Cloud",
"common.provider.huaweicloud.cdn": "Huawei Cloud - CDN",
"common.provider.qiniu": "Qiniu",
"common.provider.qiniu.cdn": "Qiniu - CDN",
"common.provider.huaweicloud.elb": "Huawei Cloud - ELB",
"common.provider.qiniu": "Qiniu Cloud",
"common.provider.qiniu.cdn": "Qiniu Cloud - CDN",
"common.provider.aws": "AWS",
"common.provider.cloudflare": "Cloudflare",
"common.provider.namesilo": "Namesilo",
@@ -73,9 +80,11 @@
"common.provider.local": "Local Deployment",
"common.provider.ssh": "SSH Deployment",
"common.provider.webhook": "Webhook",
"common.provider.serverchan": "ServerChan",
"common.provider.kubernetes": "Kubernetes",
"common.provider.kubernetes.secret": "Kubernetes - Secret",
"common.provider.dingtalk": "DingTalk",
"common.provider.telegram": "Telegram",
"common.provider.lark": "Lark"
"common.provider.lark": "Lark",
"common.provider.mail": "Mail"
}

View File

@@ -54,33 +54,98 @@
"domain.deployment.form.access.label": "Access Configuration",
"domain.deployment.form.access.placeholder": "Please select provider authorization configuration",
"domain.deployment.form.access.list": "Provider Authorization Configurations",
"domain.deployment.form.cos_region.label": "Region",
"domain.deployment.form.cos_region.placeholder": "Please enter region, e.g. ap-guangzhou",
"domain.deployment.form.cos_bucket.label": "Bucket",
"domain.deployment.form.cos_bucket.placeholder": "Please enter bucket, e.g. example-1250000000",
"domain.deployment.form.clb_region.label": "region(please distinguish between region and availability zone)",
"domain.deployment.form.clb_region.placeholder": "Please enter region, e.g. ap-guangzhou",
"domain.deployment.form.clb_id.label": "CLB id",
"domain.deployment.form.clb_id.placeholder": "Please enter CLB id, e.g. lb-xxxxxxxx",
"domain.deployment.form.clb_listener.label": "Listener id",
"domain.deployment.form.clb_listener.placeholder": "Please enter listener id, e.g. lbl-xxxxxxxx. The specific listener should have set the corresponding domain HTTPS forwarding, and the original certificate domain should be consistent with the certificate to be deployed.",
"domain.deployment.form.clb_domain.label": "Deploy to domain (Wildcard domain is also supported)",
"domain.deployment.form.clb_domain.placeholder": "Please enter domain to be deployed. If SNI is not enabled, you can leave it blank.",
"domain.deployment.form.domain.label": "Deploy to domain (Single domain only, not wildcard domain)",
"domain.deployment.form.domain.label.wildsupported": "Deploy to domain (Wildcard domain is also supported)",
"domain.deployment.form.domain.placeholder": "Please enter domain to be deployed",
"domain.deployment.form.ssh_key_path.label": "Private Key Save Path",
"domain.deployment.form.ssh_key_path.placeholder": "Please enter private key save path",
"domain.deployment.form.ssh_cert_path.label": "Certificate Save Path",
"domain.deployment.form.ssh_cert_path.placeholder": "Please enter certificate save path",
"domain.deployment.form.ssh_pre_command.label": "Pre-deployment Command",
"domain.deployment.form.ssh_pre_command.placeholder": "Command to be executed before deploying the certificate",
"domain.deployment.form.ssh_command.label": "Command",
"domain.deployment.form.ssh_command.placeholder": "Please enter command",
"domain.deployment.form.oss_endpoint.label": "Endpoint",
"domain.deployment.form.oss_endpoint.placeholder": "Please enter endpoint",
"domain.deployment.form.oss_bucket.label": "Bucket",
"domain.deployment.form.oss_bucket.placeholder": "Please enter bucket",
"domain.deployment.form.aliyun_oss_endpoint.label": "Endpoint",
"domain.deployment.form.aliyun_oss_endpoint.placeholder": "Please enter endpoint",
"domain.deployment.form.aliyun_oss_bucket.label": "Bucket",
"domain.deployment.form.aliyun_oss_bucket.placeholder": "Please enter bucket",
"domain.deployment.form.aliyun_clb_region.label": "Region",
"domain.deployment.form.aliyun_clb_region.placeholder": "Please enter region (e.g. cn-hangzhou)",
"domain.deployment.form.aliyun_clb_resource_type.label": "Resource Type",
"domain.deployment.form.aliyun_clb_resource_type.placeholder": "Please select CLB resource type",
"domain.deployment.form.aliyun_clb_resource_type.option.loadbalancer.label": "CLB LoadBalancer",
"domain.deployment.form.aliyun_clb_resource_type.option.listener.label": "CLB Listener",
"domain.deployment.form.aliyun_clb_loadbalancer_id.label": "LoadBalancer ID",
"domain.deployment.form.aliyun_clb_loadbalancer_id.placeholder": "Please enter CLB loadbalancer ID",
"domain.deployment.form.aliyun_clb_listener_port.label": "Listener Port",
"domain.deployment.form.aliyun_clb_listener_port.placeholder": "Please enter CLB listener port",
"domain.deployment.form.aliyun_alb_region.label": "Region",
"domain.deployment.form.aliyun_alb_region.placeholder": "Please enter region (e.g. cn-hangzhou)",
"domain.deployment.form.aliyun_alb_resource_type.label": "Resource Type",
"domain.deployment.form.aliyun_alb_resource_type.placeholder": "Please select ALB resource type",
"domain.deployment.form.aliyun_alb_resource_type.option.loadbalancer.label": "ALB LoadBalancer",
"domain.deployment.form.aliyun_alb_resource_type.option.listener.label": "ALB Listener",
"domain.deployment.form.aliyun_alb_loadbalancer_id.label": "LoadBalancer ID",
"domain.deployment.form.aliyun_alb_loadbalancer_id.placeholder": "Please enter ALB loadbalancer ID",
"domain.deployment.form.aliyun_alb_listener_id.label": "Listener ID",
"domain.deployment.form.aliyun_alb_listener_id.placeholder": "Please enter ALB listener ID",
"domain.deployment.form.aliyun_nlb_region.label": "Region",
"domain.deployment.form.aliyun_nlb_region.placeholder": "Please enter region (e.g. cn-hangzhou)",
"domain.deployment.form.aliyun_nlb_resource_type.label": "Resource Type",
"domain.deployment.form.aliyun_nlb_resource_type.placeholder": "Please select NLB resource type",
"domain.deployment.form.aliyun_nlb_resource_type.option.loadbalancer.label": "NLB LoadBalancer",
"domain.deployment.form.aliyun_nlb_resource_type.option.listener.label": "NLB Listener",
"domain.deployment.form.aliyun_nlb_loadbalancer_id.label": "LoadBalancer ID",
"domain.deployment.form.aliyun_nlb_loadbalancer_id.placeholder": "Please enter NLB loadbalancer ID",
"domain.deployment.form.aliyun_nlb_listener_id.label": "Listener ID",
"domain.deployment.form.aliyun_nlb_listener_id.placeholder": "Please enter NLB listener ID",
"domain.deployment.form.tencent_cos_region.label": "Region",
"domain.deployment.form.tencent_cos_region.placeholder": "Please enter region (e.g. ap-guangzhou)",
"domain.deployment.form.tencent_cos_bucket.label": "Bucket",
"domain.deployment.form.tencent_cos_bucket.placeholder": "Please enter bucket",
"domain.deployment.form.tencent_clb_region.label": "Region",
"domain.deployment.form.tencent_clb_region.placeholder": "Please enter region (e.g. ap-guangzhou)",
"domain.deployment.form.tencent_clb_id.label": "CLB ID",
"domain.deployment.form.tencent_clb_id.placeholder": "Please enter CLB ID (e.g. lb-xxxxxxxx)",
"domain.deployment.form.tencent_clb_listener.label": "Listener ID",
"domain.deployment.form.tencent_clb_listener.placeholder": "Please enter listener ID (e.g. lbl-xxxxxxxx). The specific listener should have set the corresponding domain HTTPS forwarding, and the original certificate domain should be consistent with the certificate to be deployed.",
"domain.deployment.form.tencent_clb_domain.label": "Deploy to domain (Wildcard domain is also supported)",
"domain.deployment.form.tencent_clb_domain.placeholder": "Please enter domain to be deployed. If SNI is not enabled, you can leave it blank.",
"domain.deployment.form.tencent_teo_zone_id.label": "Zone ID",
"domain.deployment.form.tencent_teo_zone_id.placeholder": "Please enter zone id, e.g. zone-xxxxxxxxx",
"domain.deployment.form.tencent_teo_domain.label": "Deploy to domain (Wildcard domain is also supported, but should be same as the config on server, one domain each line)",
"domain.deployment.form.tencent_teo_domain.placeholder": "Please enter domain to be deployed.",
"domain.deployment.form.huaweicloud_elb_region.label": "Region",
"domain.deployment.form.huaweicloud_elb_region.placeholder": "Please enter region (e.g. cn-north-1)",
"domain.deployment.form.huaweicloud_elb_resource_type.label": "Resource Type",
"domain.deployment.form.huaweicloud_elb_resource_type.placeholder": "Please select ELB resource type",
"domain.deployment.form.huaweicloud_elb_resource_type.option.certificate.label": "ELB Certificate",
"domain.deployment.form.huaweicloud_elb_resource_type.option.loadbalancer.label": "ELB LoadBalancer",
"domain.deployment.form.huaweicloud_elb_resource_type.option.listener.label": "ELB Listener",
"domain.deployment.form.huaweicloud_elb_certificate_id.label": "Certificate ID",
"domain.deployment.form.huaweicloud_elb_certificate_id.placeholder": "Please enter ELB certificate ID",
"domain.deployment.form.huaweicloud_elb_loadbalancer_id.label": "LoadBalancer ID",
"domain.deployment.form.huaweicloud_elb_loadbalancer_id.placeholder": "Please enter ELB loadbalancer ID",
"domain.deployment.form.huaweicloud_elb_listener_id.label": "Listener ID",
"domain.deployment.form.huaweicloud_elb_listener_id.placeholder": "Please enter ELB listener ID",
"domain.deployment.form.file_format.label": "Certificate Format",
"domain.deployment.form.file_format.placeholder": "Please select certificate format",
"domain.deployment.form.file_cert_path.label": "Certificate Save Path",
"domain.deployment.form.file_cert_path.placeholder": "Please enter certificate save path",
"domain.deployment.form.file_key_path.label": "Private Key Save Path",
"domain.deployment.form.file_key_path.placeholder": "Please enter private key save path",
"domain.deployment.form.file_pfx_password.label": "PFX Output Password",
"domain.deployment.form.file_pfx_password.placeholder": "Please enter PFX output password",
"domain.deployment.form.file_jks_alias.label": "JKS Alias (KeyStore Alias)",
"domain.deployment.form.file_jks_alias.placeholder": "Please enter JKS alias",
"domain.deployment.form.file_jks_keypass.label": "JKS Key Password (KeyStore Keypass)",
"domain.deployment.form.file_jks_keypass.placeholder": "Please enter JKS key password",
"domain.deployment.form.file_jks_storepass.label": "JKS Store Password (KeyStore Storepass)",
"domain.deployment.form.file_jks_storepass.placeholder": "Please enter JKS store password",
"domain.deployment.form.shell.label": "Shell",
"domain.deployment.form.shell.placeholder": "Please select shell environment",
"domain.deployment.form.shell.option.sh.label": "POSIX Bash (Linux)",
"domain.deployment.form.shell.option.cmd.label": "CMD (Windows)",
"domain.deployment.form.shell.option.powershell.label": "PowerShell (Windows)",
"domain.deployment.form.shell_pre_command.label": "Pre-deployment Command",
"domain.deployment.form.shell_pre_command.placeholder": "Command to be executed before deploying the certificate",
"domain.deployment.form.shell_command.label": "Command",
"domain.deployment.form.shell_command.placeholder": "Please enter command",
"domain.deployment.form.shell_preset_scripts.trigger": "Use Preset Scripts",
"domain.deployment.form.shell_preset_scripts.option.reload_nginx.label": "Bash - Reload Nginx",
"domain.deployment.form.shell_preset_scripts.option.binding_iis.label": "PowerShell - Binding IIS",
"domain.deployment.form.k8s_namespace.label": "Namespace",
"domain.deployment.form.k8s_namespace.placeholder": "Please enter namespace",
"domain.deployment.form.k8s_secret_name.label": "Secret Name",

View File

@@ -35,6 +35,13 @@
"settings.notification.config.push.test.message.success.message": "Send test notification successfully",
"settings.notification.dingtalk.secret.placeholder": "Signature for signed addition",
"settings.notification.url.errmsg.invalid": "Invalid Url format",
"settings.notification.serverchan.url.placeholder": "Url, e.g. https://sctapi.ftqq.com/****************.send",
"settings.notification.mail.sender_address.placeholder": "Sender email address",
"settings.notification.mail.receiver_address.placeholder": "Receiver email address",
"settings.notification.mail.smtp_host.placeholder": "SMTP server address",
"settings.notification.mail.smtp_port.placeholder": "SMTP server port, if not set, default is 25",
"settings.notification.mail.username.placeholder": "username",
"settings.notification.mail.password.placeholder": "password",
"settings.ca.tab": "Certificate Authority",
"settings.ca.provider.errmsg.empty": "Please select a Certificate Authority",

View File

@@ -12,6 +12,8 @@
"access.authorization.form.type.label": "服务商",
"access.authorization.form.type.placeholder": "请选择服务商",
"access.authorization.form.type.search.placeholder": "搜索服务商",
"access.authorization.form.type.search.notfound": "未找到服务商",
"access.authorization.form.type.list": "服务商列表",
"access.authorization.form.name.label": "名称",
"access.authorization.form.name.placeholder": "请输入授权名称",
@@ -69,7 +71,7 @@
"access.authorization.form.ssh_key_passphrase.placeholder": "请输入 Key 口令",
"access.authorization.form.webhook_url.label": "Webhook URL",
"access.authorization.form.webhook_url.placeholder": "请输入 Webhook URL",
"access.authorization.form.k8s_kubeconfig.label": "KubeConfig",
"access.authorization.form.k8s_kubeconfig.label": "KubeConfig不选将使用Pod的ServiceAccount",
"access.authorization.form.k8s_kubeconfig.placeholder": "请输入 KubeConfig",
"access.authorization.form.k8s_kubeconfig_file.placeholder": "请选择文件",
@@ -88,3 +90,4 @@
"access.group.domains": "所有授权",
"access.group.domains.nodata": "请添加域名开始部署证书吧。"
}

View File

@@ -51,19 +51,26 @@
"common.errmsg.host_invalid": "请输入正确的域名或 IP 地址",
"common.errmsg.ip_invalid": "请输入正确的 IP 地址",
"common.errmsg.url_invalid": "请输入正确的 URL",
"common.errmsg.zoneid_invalid": "请输入正确的 Zone ID",
"common.provider.tencent": "腾讯云",
"common.provider.tencent.cdn": "腾讯云 - CDN",
"common.provider.tencent.clb": "腾讯云 - CLB",
"common.provider.tencent.cos": "腾讯云 - COS",
"common.provider.aliyun": "阿里云",
"common.provider.aliyun.oss": "阿里云 - OSS",
"common.provider.aliyun.cdn": "阿里云 - CDN",
"common.provider.aliyun.dcdn": "阿里云 - DCDN",
"common.provider.aliyun.oss": "阿里云 - 对象存储 OSS",
"common.provider.aliyun.cdn": "阿里云 - 内容分发网络 CDN",
"common.provider.aliyun.dcdn": "阿里云 - 全站加速 DCDN",
"common.provider.aliyun.clb": "阿里云 - 传统型负载均衡 CLB",
"common.provider.aliyun.alb": "阿里云 - 应用型负载均衡 ALB",
"common.provider.aliyun.nlb": "阿里云 - 网络型负载均衡 NLB",
"common.provider.tencent": "腾讯云",
"common.provider.tencent.cos": "腾讯云 - 对象存储 COS",
"common.provider.tencent.cdn": "腾讯云 - 内容分发网络 CDN",
"common.provider.tencent.ecdn": "腾讯云 - 全站加速网络 ECDN",
"common.provider.tencent.clb": "腾讯云 - 负载均衡 CLB",
"common.provider.tencent.teo": "腾讯云 - 边缘安全加速平台 EO",
"common.provider.huaweicloud": "华为云",
"common.provider.huaweicloud.cdn": "华为云 - CDN",
"common.provider.huaweicloud.cdn": "华为云 - 内容分发网络 CDN",
"common.provider.huaweicloud.elb": "华为云 - 弹性负载均衡 ELB",
"common.provider.qiniu": "七牛云",
"common.provider.qiniu.cdn": "七牛云 - CDN",
"common.provider.qiniu.cdn": "七牛云 - 内容分发网络 CDN",
"common.provider.aws": "AWS",
"common.provider.cloudflare": "Cloudflare",
"common.provider.namesilo": "Namesilo",
@@ -73,10 +80,12 @@
"common.provider.local": "本地部署",
"common.provider.ssh": "SSH 部署",
"common.provider.webhook": "Webhook",
"common.provider.serverchan": "Server酱",
"common.provider.kubernetes": "Kubernetes",
"common.provider.kubernetes.secret": "Kubernetes - Secret",
"common.provider.dingtalk": "钉钉",
"common.provider.telegram": "Telegram",
"common.provider.lark": "飞书"
"common.provider.lark": "飞书",
"common.provider.mail": "电子邮件"
}

View File

@@ -54,33 +54,95 @@
"domain.deployment.form.access.label": "授权配置",
"domain.deployment.form.access.placeholder": "请选择授权配置",
"domain.deployment.form.access.list": "服务商授权配置列表",
"domain.deployment.form.cos_region.label": "region",
"domain.deployment.form.cos_region.placeholder": "请输入 region, 如 ap-guangzhou",
"domain.deployment.form.cos_bucket.label": "存储桶",
"domain.deployment.form.cos_bucket.placeholder": "请输入存储桶名, 如 example-1250000000",
"domain.deployment.form.clb_region.label": "region(地域, 请准确区分地域和可用区)",
"domain.deployment.form.clb_region.placeholder": "请输入 region, 如 ap-guangzhou",
"domain.deployment.form.clb_id.label": "CLB id",
"domain.deployment.form.clb_id.placeholder": "请输入CLB实例id, 如 lb-xxxxxxxx",
"domain.deployment.form.clb_listener.label": "监听器 id(对应监听器应已设置对应域名HTTPS转发, 且原证书对应域名应与待部署证书的一致)",
"domain.deployment.form.clb_listener.placeholder": "请输入监听器id, 如 lbl-xxxxxxxx",
"domain.deployment.form.clb_domain.label": "部署到域名(支持泛域名)",
"domain.deployment.form.clb_domain.placeholder": "请输入部署到的域名, 如未开启SNI, 可置空忽略此项",
"domain.deployment.form.domain.label": "部署到域名(仅支持单个域名;不支持泛域名)",
"domain.deployment.form.domain.label.wildsupported": "部署到域名(支持泛域名)",
"domain.deployment.form.domain.placeholder": "请输入部署到的域名",
"domain.deployment.form.ssh_key_path.label": "私钥保存路径",
"domain.deployment.form.ssh_key_path.placeholder": "请输入私钥保存路径",
"domain.deployment.form.ssh_cert_path.label": "证书保存路径",
"domain.deployment.form.ssh_cert_path.placeholder": "请输入证书保存路径",
"domain.deployment.form.ssh_pre_command.label": "前置命令",
"domain.deployment.form.ssh_pre_command.placeholder": "在部署证书前执行的命令",
"domain.deployment.form.ssh_command.label": "命令",
"domain.deployment.form.ssh_command.placeholder": "请输入要执行的命令",
"domain.deployment.form.oss_endpoint.label": "Endpoint",
"domain.deployment.form.oss_endpoint.placeholder": "请输入 Endpoint",
"domain.deployment.form.oss_bucket.label": "存储桶",
"domain.deployment.form.oss_bucket.placeholder": "请输入存储桶名",
"domain.deployment.form.aliyun_oss_endpoint.label": "Endpoint",
"domain.deployment.form.aliyun_oss_endpoint.placeholder": "请输入 Endpoint",
"domain.deployment.form.aliyun_oss_bucket.label": "存储桶",
"domain.deployment.form.aliyun_oss_bucket.placeholder": "请输入存储桶名",
"domain.deployment.form.aliyun_clb_region.label": "地域",
"domain.deployment.form.aliyun_clb_region.placeholder": "请输入地域(如 cn-hangzhou",
"domain.deployment.form.aliyun_clb_resource_type.label": "替换方式",
"domain.deployment.form.aliyun_clb_resource_type.placeholder": "请选择替换方式",
"domain.deployment.form.aliyun_clb_resource_type.option.loadbalancer.label": "替换指定负载均衡器的全部监听的证书(仅支持 HTTPS 监听)",
"domain.deployment.form.aliyun_clb_resource_type.option.listener.label": "替换指定负载均衡监听的证书",
"domain.deployment.form.aliyun_clb_loadbalancer_id.label": "负载均衡器 ID",
"domain.deployment.form.aliyun_clb_loadbalancer_id.placeholder": "请输入负载均衡器 ID",
"domain.deployment.form.aliyun_clb_listener_port.label": "监听端口",
"domain.deployment.form.aliyun_clb_listener_port.placeholder": "请输入监听端口",
"domain.deployment.form.aliyun_alb_region.label": "地域",
"domain.deployment.form.aliyun_alb_region.placeholder": "请输入地域(如 cn-hangzhou",
"domain.deployment.form.aliyun_alb_resource_type.label": "替换方式",
"domain.deployment.form.aliyun_alb_resource_type.placeholder": "请选择替换方式",
"domain.deployment.form.aliyun_alb_resource_type.option.loadbalancer.label": "替换指定负载均衡器的全部监听的证书(仅支持 HTTPS/QUIC 监听)",
"domain.deployment.form.aliyun_alb_resource_type.option.listener.label": "替换指定监听器的证书",
"domain.deployment.form.aliyun_alb_loadbalancer_id.label": "负载均衡器 ID",
"domain.deployment.form.aliyun_alb_loadbalancer_id.placeholder": "请输入负载均衡器 ID",
"domain.deployment.form.aliyun_alb_listener_id.label": "监听器 ID",
"domain.deployment.form.aliyun_alb_listener_id.placeholder": "请输入监听器 ID",
"domain.deployment.form.aliyun_nlb_region.label": "地域",
"domain.deployment.form.aliyun_nlb_region.placeholder": "请输入地域(如 cn-hangzhou",
"domain.deployment.form.aliyun_nlb_resource_type.label": "替换方式",
"domain.deployment.form.aliyun_nlb_resource_type.placeholder": "请选择替换方式",
"domain.deployment.form.aliyun_nlb_resource_type.option.loadbalancer.label": "替换指定负载均衡器的全部监听的证书(仅支持 TCPSSL 监听)",
"domain.deployment.form.aliyun_nlb_resource_type.option.listener.label": "替换指定监听器的证书",
"domain.deployment.form.aliyun_nlb_loadbalancer_id.label": "负载均衡器 ID",
"domain.deployment.form.aliyun_nlb_loadbalancer_id.placeholder": "请输入负载均衡器 ID",
"domain.deployment.form.aliyun_nlb_listener_id.label": "监听器 ID",
"domain.deployment.form.aliyun_nlb_listener_id.placeholder": "请输入监听器 ID",
"domain.deployment.form.tencent_cos_region.label": "地域",
"domain.deployment.form.tencent_cos_region.placeholder": "请输入地域(如 ap-guangzhou",
"domain.deployment.form.tencent_cos_bucket.label": "存储桶",
"domain.deployment.form.tencent_cos_bucket.placeholder": "请输入存储桶名",
"domain.deployment.form.tencent_clb_region.label": "地域",
"domain.deployment.form.tencent_clb_region.placeholder": "请输入地域(如 ap-guangzhou",
"domain.deployment.form.tencent_clb_id.label": "负载均衡器 ID",
"domain.deployment.form.tencent_clb_id.placeholder": "请输入负载均衡器实例 ID如 lb-xxxxxxxx",
"domain.deployment.form.tencent_clb_listener.label": "监听器 ID对应监听器应已设置对应域名 HTTPS 转发, 且原证书对应域名应与待部署证书的一致)",
"domain.deployment.form.tencent_clb_listener.placeholder": "请输入监听器 ID如 lb-xxxxxxxx",
"domain.deployment.form.tencent_clb_domain.label": "部署到域名(支持泛域名)",
"domain.deployment.form.tencent_clb_domain.placeholder": "请输入部署到的域名, 如未开启 SNI, 可置空忽略此项",
"domain.deployment.form.tencent_teo_zone_id.label": "Zone ID",
"domain.deployment.form.tencent_teo_zone_id.placeholder": "请输入 Zone ID",
"domain.deployment.form.tencent_teo_domain.label": "部署到域名(支持泛域名, 应与服务器上配置的域名完全一致, 每行一个域名)",
"domain.deployment.form.tencent_teo_domain.placeholder": "请输入部署到的域名",
"domain.deployment.form.huaweicloud_elb_region.label": "地域",
"domain.deployment.form.huaweicloud_elb_region.placeholder": "请输入地域(如 cn-north-1",
"domain.deployment.form.huaweicloud_elb_resource_type.label": "替换方式",
"domain.deployment.form.huaweicloud_elb_resource_type.placeholder": "请选择替换方式",
"domain.deployment.form.huaweicloud_elb_resource_type.option.certificate.label": "替换指定证书",
"domain.deployment.form.huaweicloud_elb_resource_type.option.loadbalancer.label": "替换指定负载均衡器的全部监听器的证书(仅支持 HTTPS 监听)",
"domain.deployment.form.huaweicloud_elb_resource_type.option.listener.label": "替换指定监听器",
"domain.deployment.form.huaweicloud_elb_certificate_id.label": "证书 ID",
"domain.deployment.form.huaweicloud_elb_certificate_id.placeholder": "请输入证书 ID",
"domain.deployment.form.huaweicloud_elb_loadbalancer_id.label": "负载均衡器 ID",
"domain.deployment.form.huaweicloud_elb_loadbalancer_id.placeholder": "请输入负载均衡器 ID",
"domain.deployment.form.huaweicloud_elb_listener_id.label": "监听器 ID",
"domain.deployment.form.huaweicloud_elb_listener_id.placeholder": "请输入监听器 ID",
"domain.deployment.form.file_format.label": "证书格式",
"domain.deployment.form.file_format.placeholder": "请选择证书格式",
"domain.deployment.form.file_cert_path.label": "证书保存路径",
"domain.deployment.form.file_cert_path.placeholder": "请输入证书保存路径",
"domain.deployment.form.file_key_path.label": "私钥保存路径",
"domain.deployment.form.file_key_path.placeholder": "请输入私钥保存路径",
"domain.deployment.form.file_pfx_password.label": "PFX 导出密码",
"domain.deployment.form.file_pfx_password.placeholder": "请输入 PFX 导出密码",
"domain.deployment.form.file_jks_alias.label": "JKS 别名KeyStore Alias",
"domain.deployment.form.file_jks_alias.placeholder": "请输入 JKS 别名",
"domain.deployment.form.file_jks_keypass.label": "JKS 私钥访问口令KeyStore Keypass",
"domain.deployment.form.file_jks_keypass.placeholder": "请输入 JKS 私钥访问口令",
"domain.deployment.form.file_jks_storepass.label": "JKS 密钥库存储口令KeyStore Storepass",
"domain.deployment.form.file_jks_storepass.placeholder": "请输入 JKS 密钥库存储口令",
"domain.deployment.form.shell.label": "Shell",
"domain.deployment.form.shell.placeholder": "请选择命令执行环境",
"domain.deployment.form.shell_pre_command.label": "前置命令",
"domain.deployment.form.shell_pre_command.placeholder": "在部署证书前执行的命令",
"domain.deployment.form.shell_command.label": "命令",
"domain.deployment.form.shell_command.placeholder": "请输入要执行的命令",
"domain.deployment.form.shell_preset_scripts.trigger": "使用预设脚本",
"domain.deployment.form.shell_preset_scripts.option.reload_nginx.label": "Bash - 重启 nginx",
"domain.deployment.form.shell_preset_scripts.option.binding_iis.label": "PowerShell - 导入并绑定到 IIS需管理员权限",
"domain.deployment.form.k8s_namespace.label": "命名空间",
"domain.deployment.form.k8s_namespace.placeholder": "请输入 K8S 命名空间",
"domain.deployment.form.k8s_secret_name.label": "Secret 名称",

View File

@@ -35,6 +35,13 @@
"settings.notification.config.push.test.message.success.message": "推送测试消息成功",
"settings.notification.dingtalk.secret.placeholder": "加签的签名",
"settings.notification.url.errmsg.invalid": "URL 格式不正确",
"settings.notification.serverchan.url.placeholder": "Url, 形如: https://sctapi.ftqq.com/****************.send",
"settings.notification.mail.sender_address.placeholder": "发送邮箱地址",
"settings.notification.mail.receiver_address.placeholder": "接收邮箱地址",
"settings.notification.mail.smtp_host.placeholder": "SMTP服务器地址",
"settings.notification.mail.smtp_port.placeholder": "SMTP服务器端口, 如果未设置, 默认为25",
"settings.notification.mail.username.placeholder": "用于登录到邮件服务器的用户名",
"settings.notification.mail.password.placeholder": "用于登录到邮件服务器的密码",
"settings.ca.tab": "证书颁发机构CA",
"settings.ca.provider.errmsg.empty": "请选择证书分发机构",

View File

@@ -151,4 +151,3 @@ export default function Dashboard() {
</>
);
}

View File

@@ -209,7 +209,7 @@ const Dashboard = () => {
{t("history.log")}
</SheetTitle>
</SheetHeader>
<div className="bg-gray-950 text-stone-100 p-5 text-sm h-[80dvh]">
<div className="bg-gray-950 text-stone-100 p-5 text-sm h-[80dvh] overflow-y-auto">
{deployment.log.check && (
<>
{deployment.log.check.map((item: Log) => {

View File

@@ -104,7 +104,7 @@ const History = () => {
{t("history.log")}
</SheetTitle>
</SheetHeader>
<div className="bg-gray-950 text-stone-100 p-5 text-sm h-[80dvh]">
<div className="bg-gray-950 text-stone-100 p-5 text-sm h-[80dvh] overflow-y-auto">
{deployment.log.check && (
<>
{deployment.log.check.map((item: Log) => {

View File

@@ -6,6 +6,8 @@ import Lark from "@/components/notify/Lark";
import NotifyTemplate from "@/components/notify/NotifyTemplate";
import Telegram from "@/components/notify/Telegram";
import Webhook from "@/components/notify/Webhook";
import ServerChan from "@/components/notify/ServerChan";
import Mail from "@/components/notify/Mail";
import { NotifyProvider } from "@/providers/notify";
const Notify = () => {
@@ -53,6 +55,19 @@ const Notify = () => {
<Webhook />
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-6" className="dark:border-stone-200">
<AccordionTrigger>{t("common.provider.serverchan")}</AccordionTrigger>
<AccordionContent>
<ServerChan />
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-7" className="dark:border-stone-200">
<AccordionTrigger>{t("common.provider.mail")}</AccordionTrigger>
<AccordionContent>
<Mail />
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</NotifyProvider>