From 25bd17dc6e25d3e3c800547ca3f3c1ba7b3e01a8 Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Tue, 8 Apr 2025 21:53:05 +0800 Subject: [PATCH] feat: add rainyun ssl center uploader --- .../providers/1panel-ssl/1panel_ssl.go | 6 +- .../rainyun-sslcenter/rainyun_sslcenter.go | 169 ++++++++++++++++++ .../rainyun_sslcenter_test.go | 67 +++++++ .../providers/ucloud-ussl/ucloud_ussl.go | 6 +- internal/pkg/vendors/rainyun-sdk/api.go | 30 ++++ internal/pkg/vendors/rainyun-sdk/client.go | 74 ++++++++ internal/pkg/vendors/rainyun-sdk/models.go | 83 +++++++++ 7 files changed, 429 insertions(+), 6 deletions(-) create mode 100644 internal/pkg/core/uploader/providers/rainyun-sslcenter/rainyun_sslcenter.go create mode 100644 internal/pkg/core/uploader/providers/rainyun-sslcenter/rainyun_sslcenter_test.go create mode 100644 internal/pkg/vendors/rainyun-sdk/api.go create mode 100644 internal/pkg/vendors/rainyun-sdk/client.go create mode 100644 internal/pkg/vendors/rainyun-sdk/models.go diff --git a/internal/pkg/core/uploader/providers/1panel-ssl/1panel_ssl.go b/internal/pkg/core/uploader/providers/1panel-ssl/1panel_ssl.go index ee00c06a..4eeec679 100644 --- a/internal/pkg/core/uploader/providers/1panel-ssl/1panel_ssl.go +++ b/internal/pkg/core/uploader/providers/1panel-ssl/1panel_ssl.go @@ -58,7 +58,7 @@ func (u *UploaderProvider) WithLogger(logger *slog.Logger) uploader.Uploader { func (u *UploaderProvider) Upload(ctx context.Context, certPem string, privkeyPem string) (res *uploader.UploadResult, err error) { // 遍历证书列表,避免重复上传 - if res, err := u.getExistCert(ctx, certPem, privkeyPem); err != nil { + if res, err := u.getCertIfExists(ctx, certPem, privkeyPem); err != nil { return nil, err } else if res != nil { u.logger.Info("ssl certificate already exists") @@ -82,7 +82,7 @@ func (u *UploaderProvider) Upload(ctx context.Context, certPem string, privkeyPe } // 遍历证书列表,获取刚刚上传证书 ID - if res, err := u.getExistCert(ctx, certPem, privkeyPem); err != nil { + if res, err := u.getCertIfExists(ctx, certPem, privkeyPem); err != nil { return nil, err } else if res == nil { return nil, fmt.Errorf("no ssl certificate found, may be upload failed (code: %d, message: %s)", uploadWebsiteSSLResp.GetCode(), uploadWebsiteSSLResp.GetMessage()) @@ -91,7 +91,7 @@ func (u *UploaderProvider) Upload(ctx context.Context, certPem string, privkeyPe } } -func (u *UploaderProvider) getExistCert(ctx context.Context, certPem string, privkeyPem string) (res *uploader.UploadResult, err error) { +func (u *UploaderProvider) getCertIfExists(ctx context.Context, certPem string, privkeyPem string) (res *uploader.UploadResult, err error) { searchWebsiteSSLPageNumber := int32(1) searchWebsiteSSLPageSize := int32(100) for { diff --git a/internal/pkg/core/uploader/providers/rainyun-sslcenter/rainyun_sslcenter.go b/internal/pkg/core/uploader/providers/rainyun-sslcenter/rainyun_sslcenter.go new file mode 100644 index 00000000..f2ee4bde --- /dev/null +++ b/internal/pkg/core/uploader/providers/rainyun-sslcenter/rainyun_sslcenter.go @@ -0,0 +1,169 @@ +package rainyunsslcenter + +import ( + "context" + "errors" + "fmt" + "log/slog" + "strings" + + xerrors "github.com/pkg/errors" + + "github.com/usual2970/certimate/internal/pkg/core/uploader" + "github.com/usual2970/certimate/internal/pkg/utils/certutil" + rainyunsdk "github.com/usual2970/certimate/internal/pkg/vendors/rainyun-sdk" +) + +type UploaderConfig struct { + // 雨云 API 密钥。 + ApiKey string `json:"ApiKey"` +} + +type UploaderProvider struct { + config *UploaderConfig + logger *slog.Logger + sdkClient *rainyunsdk.Client +} + +var _ uploader.Uploader = (*UploaderProvider)(nil) + +func NewUploader(config *UploaderConfig) (*UploaderProvider, error) { + if config == nil { + panic("config is nil") + } + + client, err := createSdkClient(config.ApiKey) + if err != nil { + return nil, xerrors.Wrap(err, "failed to create sdk client") + } + + return &UploaderProvider{ + config: config, + logger: slog.Default(), + sdkClient: client, + }, nil +} + +func (u *UploaderProvider) WithLogger(logger *slog.Logger) uploader.Uploader { + if logger == nil { + u.logger = slog.Default() + } else { + u.logger = logger + } + return u +} + +func (u *UploaderProvider) Upload(ctx context.Context, certPem string, privkeyPem string) (res *uploader.UploadResult, err error) { + if res, err := u.getCertIfExists(ctx, certPem); err != nil { + return nil, err + } else if res != nil { + u.logger.Info("ssl certificate already exists") + return res, nil + } + + // SSL 证书上传 + // REF: https://apifox.com/apidoc/shared/a4595cc8-44c5-4678-a2a3-eed7738dab03/api-69943046 + sslCenterCreateReq := &rainyunsdk.SslCenterCreateRequest{ + Cert: certPem, + Key: privkeyPem, + } + sslCenterCreateResp, err := u.sdkClient.SslCenterCreate(sslCenterCreateReq) + u.logger.Debug("sdk request 'sslcenter.Create'", slog.Any("request", sslCenterCreateReq), slog.Any("response", sslCenterCreateResp)) + if err != nil { + return nil, xerrors.Wrap(err, "failed to execute sdk request 'sslcenter.Create'") + } + + if res, err := u.getCertIfExists(ctx, certPem); err != nil { + return nil, err + } else if res == nil { + return nil, errors.New("rainyun sslcenter: no certificate found") + } else { + return res, nil + } +} + +func (u *UploaderProvider) getCertIfExists(ctx context.Context, certPem string) (res *uploader.UploadResult, err error) { + // 解析证书内容 + certX509, err := certutil.ParseCertificateFromPEM(certPem) + if err != nil { + return nil, err + } + + // 遍历 SSL 证书列表,避免重复上传 + // REF: https://apifox.com/apidoc/shared/a4595cc8-44c5-4678-a2a3-eed7738dab03/api-69943046 + // REF: https://apifox.com/apidoc/shared/a4595cc8-44c5-4678-a2a3-eed7738dab03/api-69943048 + sslCenterListPage := int32(1) + sslCenterListPerPage := int32(100) + for { + sslCenterListReq := &rainyunsdk.SslCenterListRequest{ + Filters: &rainyunsdk.SslCenterListFilters{ + Domain: &certX509.Subject.CommonName, + }, + Page: &sslCenterListPage, + PerPage: &sslCenterListPerPage, + } + sslCenterListResp, err := u.sdkClient.SslCenterList(sslCenterListReq) + u.logger.Debug("sdk request 'sslcenter.List'", slog.Any("request", sslCenterListReq), slog.Any("response", sslCenterListResp)) + if err != nil { + return nil, xerrors.Wrap(err, "failed to execute sdk request 'sslcenter.List'") + } + + if sslCenterListResp.Data != nil && sslCenterListResp.Data.Records != nil { + for _, sslItem := range sslCenterListResp.Data.Records { + // 先对比证书的多域名 + if sslItem.Domain != strings.Join(certX509.DNSNames, ", ") { + continue + } + + // 再对比证书的有效期 + if sslItem.StartDate != certX509.NotBefore.Unix() || sslItem.ExpireDate != certX509.NotAfter.Unix() { + continue + } + + // 最后对比证书内容 + sslCenterGetResp, err := u.sdkClient.SslCenterGet(sslItem.ID) + if err != nil { + return nil, xerrors.Wrap(err, "failed to execute sdk request 'sslcenter.Get'") + } + + var isSameCert bool + if sslCenterGetResp.Data != nil { + if sslCenterGetResp.Data.Cert == certPem { + isSameCert = true + } else { + oldCertX509, err := certutil.ParseCertificateFromPEM(sslCenterGetResp.Data.Cert) + if err != nil { + continue + } + + isSameCert = certutil.EqualCertificate(certX509, oldCertX509) + } + } + + // 如果已存在相同证书,直接返回 + if isSameCert { + return &uploader.UploadResult{ + CertId: fmt.Sprintf("%d", sslItem.ID), + }, nil + } + } + } + + if sslCenterListResp.Data == nil || len(sslCenterListResp.Data.Records) < int(sslCenterListPerPage) { + break + } else { + sslCenterListPage++ + } + } + + return nil, nil +} + +func createSdkClient(apiKey string) (*rainyunsdk.Client, error) { + if apiKey == "" { + return nil, errors.New("invalid rainyun api key") + } + + client := rainyunsdk.NewClient(apiKey) + return client, nil +} diff --git a/internal/pkg/core/uploader/providers/rainyun-sslcenter/rainyun_sslcenter_test.go b/internal/pkg/core/uploader/providers/rainyun-sslcenter/rainyun_sslcenter_test.go new file mode 100644 index 00000000..41619401 --- /dev/null +++ b/internal/pkg/core/uploader/providers/rainyun-sslcenter/rainyun_sslcenter_test.go @@ -0,0 +1,67 @@ +package rainyunsslcenter_test + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + "strings" + "testing" + + provider "github.com/usual2970/certimate/internal/pkg/core/uploader/providers/rainyun-sslcenter" +) + +var ( + fInputCertPath string + fInputKeyPath string + fApiKey string +) + +func init() { + argsPrefix := "CERTIMATE_UPLOADER_RAINYUNSSLCENTER_" + + flag.StringVar(&fInputCertPath, argsPrefix+"INPUTCERTPATH", "", "") + flag.StringVar(&fInputKeyPath, argsPrefix+"INPUTKEYPATH", "", "") + flag.StringVar(&fApiKey, argsPrefix+"APIKEY", "", "") +} + +/* +Shell command to run this test: + + go test -v ./rainyun_sslcenter_test.go -args \ + --CERTIMATE_UPLOADER_RAINYUNSSLCENTER_INPUTCERTPATH="/path/to/your-input-cert.pem" \ + --CERTIMATE_UPLOADER_RAINYUNSSLCENTER_INPUTKEYPATH="/path/to/your-input-key.pem" \ + --CERTIMATE_UPLOADER_RAINYUNSSLCENTER_APIKEY="your-api-key" +*/ +func TestDeploy(t *testing.T) { + flag.Parse() + + t.Run("Deploy", func(t *testing.T) { + t.Log(strings.Join([]string{ + "args:", + fmt.Sprintf("INPUTCERTPATH: %v", fInputCertPath), + fmt.Sprintf("INPUTKEYPATH: %v", fInputKeyPath), + fmt.Sprintf("APIKEY: %v", fApiKey), + }, "\n")) + + uploader, err := provider.NewUploader(&provider.UploaderConfig{ + ApiKey: fApiKey, + }) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + fInputCertData, _ := os.ReadFile(fInputCertPath) + fInputKeyData, _ := os.ReadFile(fInputKeyPath) + res, err := uploader.Upload(context.Background(), string(fInputCertData), string(fInputKeyData)) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + sres, _ := json.Marshal(res) + t.Logf("ok: %s", string(sres)) + }) +} diff --git a/internal/pkg/core/uploader/providers/ucloud-ussl/ucloud_ussl.go b/internal/pkg/core/uploader/providers/ucloud-ussl/ucloud_ussl.go index b8639bf3..4649c454 100644 --- a/internal/pkg/core/uploader/providers/ucloud-ussl/ucloud_ussl.go +++ b/internal/pkg/core/uploader/providers/ucloud-ussl/ucloud_ussl.go @@ -89,10 +89,10 @@ func (u *UploaderProvider) Upload(ctx context.Context, certPem string, privkeyPe u.logger.Debug("sdk request 'ussl.UploadNormalCertificate'", slog.Any("request", uploadNormalCertificateReq), slog.Any("response", uploadNormalCertificateResp)) if err != nil { if uploadNormalCertificateResp != nil && uploadNormalCertificateResp.GetRetCode() == 80035 { - if res, err := u.getExistCert(ctx, certPem); err != nil { + if res, err := u.getCertIfExists(ctx, certPem); err != nil { return nil, err } else if res == nil { - return nil, errors.New("no certificate found") + return nil, errors.New("ucloud ssl: no certificate found") } else { u.logger.Info("ssl certificate already exists") return res, nil @@ -112,7 +112,7 @@ func (u *UploaderProvider) Upload(ctx context.Context, certPem string, privkeyPe }, nil } -func (u *UploaderProvider) getExistCert(ctx context.Context, certPem string) (res *uploader.UploadResult, err error) { +func (u *UploaderProvider) getCertIfExists(ctx context.Context, certPem string) (res *uploader.UploadResult, err error) { // 解析证书内容 certX509, err := certutil.ParseCertificateFromPEM(certPem) if err != nil { diff --git a/internal/pkg/vendors/rainyun-sdk/api.go b/internal/pkg/vendors/rainyun-sdk/api.go new file mode 100644 index 00000000..50f3279d --- /dev/null +++ b/internal/pkg/vendors/rainyun-sdk/api.go @@ -0,0 +1,30 @@ +package rainyunsdk + +import ( + "fmt" + "net/http" +) + +func (c *Client) SslCenterList(req *SslCenterListRequest) (*SslCenterListResponse, error) { + resp := &SslCenterListResponse{} + err := c.sendRequestWithResult(http.MethodGet, "/product/sslcenter", req, resp) + return resp, err +} + +func (c *Client) SslCenterGet(id int32) (*SslCenterGetResponse, error) { + resp := &SslCenterGetResponse{} + err := c.sendRequestWithResult(http.MethodGet, fmt.Sprintf("/product/sslcenter/%d", id), nil, resp) + return resp, err +} + +func (c *Client) SslCenterCreate(req *SslCenterCreateRequest) (*SslCenterCreateResponse, error) { + resp := &SslCenterCreateResponse{} + err := c.sendRequestWithResult(http.MethodPost, "/product/sslcenter/", req, resp) + return resp, err +} + +func (c *Client) RcdnInstanceSslBind(id int32, req *RcdnInstanceSslBindRequest) (*RcdnInstanceSslBindResponse, error) { + resp := &RcdnInstanceSslBindResponse{} + err := c.sendRequestWithResult(http.MethodPost, fmt.Sprintf("/product/rcdn/instance/%d/ssl_bind", id), req, resp) + return resp, err +} diff --git a/internal/pkg/vendors/rainyun-sdk/client.go b/internal/pkg/vendors/rainyun-sdk/client.go new file mode 100644 index 00000000..b35f38e0 --- /dev/null +++ b/internal/pkg/vendors/rainyun-sdk/client.go @@ -0,0 +1,74 @@ +package rainyunsdk + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/go-resty/resty/v2" +) + +type Client struct { + apiKey string + + client *resty.Client +} + +func NewClient(apiKey string) *Client { + client := resty.New() + + return &Client{ + apiKey: apiKey, + client: client, + } +} + +func (c *Client) WithTimeout(timeout time.Duration) *Client { + c.client.SetTimeout(timeout) + return c +} + +func (c *Client) sendRequest(method string, path string, params interface{}) (*resty.Response, error) { + req := c.client.R().SetHeader("x-api-key", c.apiKey) + req.Method = method + req.URL = "https://api.v2.rainyun.com" + path + if strings.EqualFold(method, http.MethodGet) { + if params != nil { + jsonb, _ := json.Marshal(params) + req = req.SetQueryParam("options", string(jsonb)) + } + } else { + req = req. + SetHeader("Content-Type", "application/json"). + SetBody(params) + } + + resp, err := req.Send() + if err != nil { + return resp, fmt.Errorf("rainyun api error: failed to send request: %w", err) + } else if resp.IsError() { + return resp, fmt.Errorf("rainyun api error: unexpected status code: %d, %s", resp.StatusCode(), resp.Body()) + } + + return resp, nil +} + +func (c *Client) sendRequestWithResult(method string, path string, params interface{}, result BaseResponse) error { + resp, err := c.sendRequest(method, path, params) + if err != nil { + if resp != nil { + json.Unmarshal(resp.Body(), &result) + } + return err + } + + if err := json.Unmarshal(resp.Body(), &result); err != nil { + return fmt.Errorf("rainyun api error: failed to parse response: %w", err) + } else if errcode := result.GetCode(); errcode/100 != 2 { + return fmt.Errorf("rainyun api error: %d - %s", errcode, result.GetMessage()) + } + + return nil +} diff --git a/internal/pkg/vendors/rainyun-sdk/models.go b/internal/pkg/vendors/rainyun-sdk/models.go new file mode 100644 index 00000000..bcc44963 --- /dev/null +++ b/internal/pkg/vendors/rainyun-sdk/models.go @@ -0,0 +1,83 @@ +package rainyunsdk + +type BaseResponse interface { + GetCode() int32 + GetMessage() string +} + +type baseResponse struct { + Code *int32 `json:"code,omitempty"` + Message *string `json:"message,omitempty"` +} + +func (r *baseResponse) GetCode() int32 { + if r.Code != nil { + return *r.Code + } + return 0 +} + +func (r *baseResponse) GetMessage() string { + if r.Message != nil { + return *r.Message + } + return "" +} + +type SslCenterListFilters struct { + Domain *string `json:"Domain,omitempty"` +} + +type SslCenterListRequest struct { + Filters *SslCenterListFilters `json:"columnFilters,omitempty"` + Sort []*string `json:"sort,omitempty"` + Page *int32 `json:"page,omitempty"` + PerPage *int32 `json:"perPage,omitempty"` +} + +type SslCenterListResponse struct { + baseResponse + Data *struct { + TotalRecords int32 `json:"TotalRecords"` + Records []*struct { + ID int32 `json:"ID"` + UID int32 `json:"UID"` + Domain string `json:"Domain"` + Issuer string `json:"Issuer"` + StartDate int64 `json:"StartDate"` + ExpireDate int64 `json:"ExpDate"` + UploadTime int64 `json:"UploadTime"` + } `json:"Records"` + } `json:"data,omitempty"` +} + +type SslCenterGetResponse struct { + baseResponse + Data *struct { + Cert string `json:"Cert"` + Key string `json:"Key"` + Domain string `json:"DomainName"` + Issuer string `json:"Issuer"` + StartDate int64 `json:"StartDate"` + ExpireDate int64 `json:"ExpDate"` + RemainDays int32 `json:"RemainDays"` + } `json:"data,omitempty"` +} + +type SslCenterCreateRequest struct { + Cert string `json:"cert"` + Key string `json:"key"` +} + +type SslCenterCreateResponse struct { + baseResponse +} + +type RcdnInstanceSslBindRequest struct { + CertId int32 `json:"cert_id"` + Domains []string `json:"domains"` +} + +type RcdnInstanceSslBindResponse struct { + baseResponse +}