diff --git a/internal/deployer/providers.go b/internal/deployer/providers.go index 292c34f7..c2136d20 100644 --- a/internal/deployer/providers.go +++ b/internal/deployer/providers.go @@ -74,6 +74,7 @@ import ( pVolcEngineImageX "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/volcengine-imagex" pVolcEngineLive "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/volcengine-live" pVolcEngineTOS "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/volcengine-tos" + pWangsuCDNPro "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/wangsu-cdnpro" pWebhook "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/webhook" "github.com/usual2970/certimate/internal/pkg/utils/maputil" "github.com/usual2970/certimate/internal/pkg/utils/sliceutil" @@ -1003,6 +1004,30 @@ func createDeployer(options *deployerOptions) (deployer.Deployer, error) { } } + case domain.DeployProviderTypeWangsuCDNPro: + { + access := domain.AccessConfigForWangsu{} + if err := maputil.Populate(options.ProviderAccessConfig, &access); err != nil { + return nil, fmt.Errorf("failed to populate provider access config: %w", err) + } + + switch options.Provider { + case domain.DeployProviderTypeWangsuCDNPro: + deployer, err := pWangsuCDNPro.NewDeployer(&pWangsuCDNPro.DeployerConfig{ + AccessKeyId: access.AccessKeyId, + AccessKeySecret: access.AccessKeySecret, + Environment: maputil.GetOrDefaultString(options.ProviderDeployConfig, "environment", "production"), + Domain: maputil.GetString(options.ProviderDeployConfig, "domain"), + CertificateId: maputil.GetString(options.ProviderDeployConfig, "certificateId"), + WebhookId: maputil.GetString(options.ProviderDeployConfig, "webhookId"), + }) + return deployer, err + + default: + break + } + } + case domain.DeployProviderTypeWebhook: { access := domain.AccessConfigForWebhook{} diff --git a/internal/domain/access.go b/internal/domain/access.go index 0d3528ab..9e419eaa 100644 --- a/internal/domain/access.go +++ b/internal/domain/access.go @@ -228,6 +228,11 @@ type AccessConfigForVolcEngine struct { SecretAccessKey string `json:"secretAccessKey"` } +type AccessConfigForWangsu struct { + AccessKeyId string `json:"accessKeyId"` + AccessKeySecret string `json:"accessKeySecret"` +} + type AccessConfigForWebhook struct { Url string `json:"url"` AllowInsecureConnections bool `json:"allowInsecureConnections,omitempty"` diff --git a/internal/domain/provider.go b/internal/domain/provider.go index 18ee73b9..668612f7 100644 --- a/internal/domain/provider.go +++ b/internal/domain/provider.go @@ -61,6 +61,7 @@ const ( AccessProviderTypeUpyun = AccessProviderType("upyun") AccessProviderTypeVercel = AccessProviderType("vercel") AccessProviderTypeVolcEngine = AccessProviderType("volcengine") + AccessProviderTypeWangsu = AccessProviderType("wangsu") AccessProviderTypeWebhook = AccessProviderType("webhook") AccessProviderTypeWestcn = AccessProviderType("westcn") AccessProviderTypeZeroSSL = AccessProviderType("zerossl") @@ -212,5 +213,6 @@ const ( DeployProviderTypeVolcEngineImageX = DeployProviderType("volcengine-imagex") DeployProviderTypeVolcEngineLive = DeployProviderType("volcengine-live") DeployProviderTypeVolcEngineTOS = DeployProviderType("volcengine-tos") + DeployProviderTypeWangsuCDNPro = DeployProviderType("wangsu-cdnpro") DeployProviderTypeWebhook = DeployProviderType("webhook") ) diff --git a/internal/pkg/core/deployer/providers/wangsu-cdnpro/wangsu_cdnpro.go b/internal/pkg/core/deployer/providers/wangsu-cdnpro/wangsu_cdnpro.go new file mode 100644 index 00000000..c5ac15b9 --- /dev/null +++ b/internal/pkg/core/deployer/providers/wangsu-cdnpro/wangsu_cdnpro.go @@ -0,0 +1,276 @@ +package wangsucdnpro + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "log/slog" + "regexp" + "time" + + "github.com/alibabacloud-go/tea/tea" + xerrors "github.com/pkg/errors" + + "github.com/usual2970/certimate/internal/pkg/core/deployer" + "github.com/usual2970/certimate/internal/pkg/utils/certutil" + wangsucdn "github.com/usual2970/certimate/internal/pkg/vendors/wangsu-sdk/cdn" +) + +type DeployerConfig struct { + // 网宿云 AccessKeyId。 + AccessKeyId string `json:"accessKeyId"` + // 网宿云 AccessKeySecret。 + AccessKeySecret string `json:"accessKeySecret"` + // 网宿云环境。 + Environment string `json:"environment"` + // 加速域名(支持泛域名)。 + Domain string `json:"domain"` + // 证书 ID。 + // 选填。 + CertificateId string `json:"certificateId,omitempty"` + // Webhook ID。 + // 选填。 + WebhookId string `json:"webhookId,omitempty"` +} + +type DeployerProvider struct { + config *DeployerConfig + logger *slog.Logger + sdkClient *wangsucdn.Client +} + +var _ deployer.Deployer = (*DeployerProvider)(nil) + +func NewDeployer(config *DeployerConfig) (*DeployerProvider, error) { + if config == nil { + panic("config is nil") + } + + client, err := createSdkClient(config.AccessKeyId, config.AccessKeySecret) + if err != nil { + return nil, xerrors.Wrap(err, "failed to create sdk client") + } + + return &DeployerProvider{ + config: config, + logger: slog.Default(), + sdkClient: client, + }, nil +} + +func (d *DeployerProvider) WithLogger(logger *slog.Logger) deployer.Deployer { + if logger == nil { + d.logger = slog.Default() + } else { + d.logger = logger + } + return d +} + +func (d *DeployerProvider) Deploy(ctx context.Context, certPem string, privkeyPem string) (*deployer.DeployResult, error) { + if d.config.Domain == "" { + return nil, errors.New("config `domain` is required") + } + + // 解析证书内容 + certX509, err := certutil.ParseCertificateFromPEM(certPem) + if err != nil { + return nil, err + } + + // 查询已部署加速域名的详情 + getHostnameDetailResp, err := d.sdkClient.GetHostnameDetail(d.config.Domain) + d.logger.Debug("sdk request 'cdn.GetHostnameDetail'", slog.String("hostname", d.config.Domain), slog.Any("response", getHostnameDetailResp)) + if err != nil { + return nil, xerrors.Wrap(err, "failed to execute sdk request 'cdn.GetHostnameDetail'") + } + + // 生成网宿云证书参数 + encryptedPrivateKey, err := encryptPrivateKey(privkeyPem, d.config.AccessKeySecret, time.Now().Unix()) + if err != nil { + return nil, xerrors.Wrap(err, "failed to encrypt private key") + } + certificateNewVersionInfo := &wangsucdn.CertificateVersion{ + PrivateKey: tea.String(encryptedPrivateKey), + Certificate: tea.String(certPem), + IdentificationInfo: &wangsucdn.CertificateVersionIdentificationInfo{ + CommonName: tea.String(certX509.Subject.CommonName), + SubjectAlternativeNames: &certX509.DNSNames, + }, + } + + // 网宿云证书 URL 中包含证书 ID 及版本号 + // 格式: + // http://open.chinanetcenter.com/cdn/certificates/5dca2205f9e9cc0001df7b33 + // http://open.chinanetcenter.com/cdn/certificates/329f12c1fe6708c23c31e91f/versions/5 + var wangsuCertUrl string + var wangsuCertId, wangsuCertVer string + + // 如果原证书 ID 为空,则创建证书;否则更新证书。 + timestamp := time.Now().Unix() + if d.config.CertificateId == "" { + // 创建证书 + createCertificateReq := &wangsucdn.CreateCertificateRequest{ + Timestamp: timestamp, + Name: tea.String(fmt.Sprintf("certimate_%d", time.Now().UnixMilli())), + AutoRenew: tea.String("Off"), + NewVersion: certificateNewVersionInfo, + } + createCertificateResp, err := d.sdkClient.CreateCertificate(createCertificateReq) + d.logger.Debug("sdk request 'cdn.CreateCertificate'", slog.Any("request", createCertificateReq), slog.Any("response", createCertificateResp)) + if err != nil { + return nil, xerrors.Wrap(err, "failed to execute sdk request 'cdn.CreateCertificate'") + } + + wangsuCertUrl = createCertificateResp.CertificateUrl + d.logger.Info("ssl certificate uploaded", slog.Any("certUrl", wangsuCertUrl)) + + wangsuCertIdMatches := regexp.MustCompile(`/certificates/([a-zA-Z0-9-]+)`).FindStringSubmatch(wangsuCertUrl) + if len(wangsuCertIdMatches) > 1 { + wangsuCertId = wangsuCertIdMatches[1] + } + + wangsuCertVer = "1" + } else { + // 更新证书 + updateCertificateReq := &wangsucdn.UpdateCertificateRequest{ + Timestamp: timestamp, + Name: tea.String(fmt.Sprintf("certimate_%d", time.Now().UnixMilli())), + AutoRenew: tea.String("Off"), + NewVersion: certificateNewVersionInfo, + } + updateCertificateResp, err := d.sdkClient.UpdateCertificate(d.config.CertificateId, updateCertificateReq) + d.logger.Debug("sdk request 'cdn.CreateCertificate'", slog.Any("certificateId", d.config.CertificateId), slog.Any("request", updateCertificateReq), slog.Any("response", updateCertificateResp)) + if err != nil { + return nil, xerrors.Wrap(err, "failed to execute sdk request 'cdn.UpdateCertificate'") + } + + wangsuCertUrl = updateCertificateResp.CertificateUrl + d.logger.Info("ssl certificate uploaded", slog.Any("certUrl", wangsuCertUrl)) + + wangsuCertIdMatches := regexp.MustCompile(`/certificates/([a-zA-Z0-9-]+)`).FindStringSubmatch(wangsuCertUrl) + if len(wangsuCertIdMatches) > 1 { + wangsuCertId = wangsuCertIdMatches[1] + } + + wangsuCertVerMatches := regexp.MustCompile(`/versions/(\d+)`).FindStringSubmatch(wangsuCertUrl) + if len(wangsuCertVerMatches) > 1 { + wangsuCertVer = wangsuCertVerMatches[1] + } + } + + // 创建部署任务 + // REF: https://www.wangsu.com/document/api-doc/27034 + createDeploymentTaskReq := &wangsucdn.CreateDeploymentTaskRequest{ + Name: tea.String(fmt.Sprintf("certimate_%d", time.Now().UnixMilli())), + Target: tea.String(d.config.Environment), + Actions: &[]wangsucdn.DeploymentTaskAction{ + { + Action: tea.String("deploy_cert"), + CertificateId: tea.String(wangsuCertId), + Version: tea.String(wangsuCertVer), + }, + }, + } + if d.config.WebhookId != "" { + createDeploymentTaskReq.Webhook = tea.String(d.config.WebhookId) + } + createDeploymentTaskResp, err := d.sdkClient.CreateDeploymentTask(createDeploymentTaskReq) + d.logger.Debug("sdk request 'cdn.CreateCertificate'", slog.Any("request", createDeploymentTaskReq), slog.Any("response", createDeploymentTaskResp)) + if err != nil { + return nil, xerrors.Wrap(err, "failed to execute sdk request 'cdn.CreateDeploymentTask'") + } + + // 循环获取部署任务详细信息,等待任务状态变更 + // REF: https://www.wangsu.com/document/api-doc/27038 + var wangsuTaskId string + wangsuTaskMatches := regexp.MustCompile(`/deploymentTasks/([a-zA-Z0-9-]+)`).FindStringSubmatch(wangsuCertUrl) + if len(wangsuTaskMatches) > 1 { + wangsuTaskId = wangsuTaskMatches[1] + } + for { + if ctx.Err() != nil { + return nil, ctx.Err() + } + + getDeploymentTaskDetailResp, err := d.sdkClient.GetDeploymentTaskDetail(wangsuTaskId) + d.logger.Debug("sdk request 'cdn.GetDeploymentTaskDetail'", slog.Any("taskId", wangsuTaskId), slog.Any("response", getDeploymentTaskDetailResp)) + if err != nil { + return nil, xerrors.Wrap(err, "failed to execute sdk request 'cdn.GetDeploymentTaskDetail'") + } + + if getDeploymentTaskDetailResp.Status == "failed" { + return nil, errors.New("unexpected deployment task status") + } else if getDeploymentTaskDetailResp.Status == "succeeded" { + break + } + + d.logger.Info("waiting for deployment task completion ...") + time.Sleep(time.Second * 15) + } + + return &deployer.DeployResult{}, nil +} + +func createSdkClient(accessKeyId, accessKeySecret string) (*wangsucdn.Client, error) { + if accessKeyId == "" { + return nil, errors.New("invalid wangsu access key id") + } + + if accessKeySecret == "" { + return nil, errors.New("invalid wangsu access key secret") + } + + return wangsucdn.NewClient(accessKeyId, accessKeySecret), nil +} + +func encryptPrivateKey(privkeyPem string, secretKey string, timestamp int64) (string, error) { + date := time.Unix(timestamp, 0).UTC() + dateStr := date.Format("Mon, 02 Jan 2006 15:04:05 GMT") + + mac := hmac.New(sha256.New, []byte(secretKey)) + mac.Write([]byte(dateStr)) + aesivkey := mac.Sum(nil) + aesivkeyHex := hex.EncodeToString(aesivkey) + + if len(aesivkeyHex) != 64 { + return "", fmt.Errorf("invalid hmac length: %d", len(aesivkeyHex)) + } + ivHex := aesivkeyHex[:32] + keyHex := aesivkeyHex[32:64] + + iv, err := hex.DecodeString(ivHex) + if err != nil { + return "", fmt.Errorf("failed to decode iv: %w", err) + } + + key, err := hex.DecodeString(keyHex) + if err != nil { + return "", fmt.Errorf("failed to decode key: %w", err) + } + + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + + plainBytes := []byte(privkeyPem) + padlen := aes.BlockSize - len(plainBytes)%aes.BlockSize + if padlen > 0 { + paddata := bytes.Repeat([]byte{byte(padlen)}, padlen) + plainBytes = append(plainBytes, paddata...) + } + + encBytes := make([]byte, len(plainBytes)) + mode := cipher.NewCBCEncrypter(block, iv) + mode.CryptBlocks(encBytes, plainBytes) + + return base64.StdEncoding.EncodeToString(encBytes), nil +} diff --git a/internal/pkg/core/deployer/providers/wangsu-cdnpro/wangsu_cdnpro_test.go b/internal/pkg/core/deployer/providers/wangsu-cdnpro/wangsu_cdnpro_test.go new file mode 100644 index 00000000..25dd7b1e --- /dev/null +++ b/internal/pkg/core/deployer/providers/wangsu-cdnpro/wangsu_cdnpro_test.go @@ -0,0 +1,90 @@ +package wangsucdnpro_test + +import ( + "context" + "flag" + "fmt" + "os" + "strings" + "testing" + + provider "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/wangsu-cdnpro" +) + +var ( + fInputCertPath string + fInputKeyPath string + fAccessKeyId string + fAccessKeySecret string + fEnvironment string + fDomain string + fCertificateId string + fWebhookId string +) + +func init() { + argsPrefix := "CERTIMATE_DEPLOYER_WANGSUCDNPRO_" + + flag.StringVar(&fInputCertPath, argsPrefix+"INPUTCERTPATH", "", "") + flag.StringVar(&fInputKeyPath, argsPrefix+"INPUTKEYPATH", "", "") + flag.StringVar(&fAccessKeyId, argsPrefix+"ACCESSKEYID", "", "") + flag.StringVar(&fAccessKeySecret, argsPrefix+"ACCESSKEYSECRET", "", "") + flag.StringVar(&fEnvironment, argsPrefix+"ENVIRONMENT", "production", "") + flag.StringVar(&fDomain, argsPrefix+"DOMAIN", "", "") + flag.StringVar(&fCertificateId, argsPrefix+"CERTIFICATEID", "", "") + flag.StringVar(&fWebhookId, argsPrefix+"WEBHOOKID", "", "") +} + +/* +Shell command to run this test: + + go test -v ./wangsu_cdnpro_test.go -args \ + --CERTIMATE_DEPLOYER_WANGSUCDNPRO_INPUTCERTPATH="/path/to/your-input-cert.pem" \ + --CERTIMATE_DEPLOYER_WANGSUCDNPRO_INPUTKEYPATH="/path/to/your-input-key.pem" \ + --CERTIMATE_DEPLOYER_WANGSUCDNPRO_ACCESSKEYID="your-access-key-id" \ + --CERTIMATE_DEPLOYER_WANGSUCDNPRO_ACCESSKEYSECRET="your-access-key-secret" \ + --CERTIMATE_DEPLOYER_WANGSUCDNPRO_ENVIRONMENT="production" \ + --CERTIMATE_DEPLOYER_WANGSUCDNPRO_DOMAIN="example.com" \ + --CERTIMATE_DEPLOYER_WANGSUCDNPRO_CERTIFICATEID="your-certificate-id"\ + --CERTIMATE_DEPLOYER_WANGSUCDNPRO_WEBHOOKID="your-webhook-id" +*/ +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("ACCESSKEYID: %v", fAccessKeyId), + fmt.Sprintf("ACCESSKEYSECRET: %v", fAccessKeySecret), + fmt.Sprintf("ENVIRONMENT: %v", fEnvironment), + fmt.Sprintf("DOMAIN: %v", fDomain), + fmt.Sprintf("CERTIFICATEID: %v", fCertificateId), + fmt.Sprintf("WEBHOOKID: %v", fWebhookId), + }, "\n")) + + deployer, err := provider.NewDeployer(&provider.DeployerConfig{ + AccessKeyId: fAccessKeyId, + AccessKeySecret: fAccessKeySecret, + Environment: fEnvironment, + Domain: fDomain, + CertificateId: fCertificateId, + WebhookId: fWebhookId, + }) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + fInputCertData, _ := os.ReadFile(fInputCertPath) + fInputKeyData, _ := os.ReadFile(fInputKeyPath) + res, err := deployer.Deploy(context.Background(), string(fInputCertData), string(fInputKeyData)) + if err != nil { + t.Errorf("err: %+v", err) + return + } + + t.Logf("ok: %v", res) + }) +} diff --git a/internal/pkg/vendors/wangsu-sdk/cdn/api.go b/internal/pkg/vendors/wangsu-sdk/cdn/api.go new file mode 100644 index 00000000..fd96ba2f --- /dev/null +++ b/internal/pkg/vendors/wangsu-sdk/cdn/api.go @@ -0,0 +1,58 @@ +package cdn + +import ( + "fmt" + "net/http" + "net/url" + + "github.com/go-resty/resty/v2" +) + +func (c *Client) CreateCertificate(req *CreateCertificateRequest) (*CreateCertificateResponse, error) { + resp := &CreateCertificateResponse{} + r, err := c.client.SendRequestWithResult(http.MethodPost, "/cdn/certificates", req, resp, func(r *resty.Request) { + r.SetHeader("x-cnc-timestamp", fmt.Sprintf("%d", req.Timestamp)) + }) + if err != nil { + return resp, err + } + + resp.CertificateUrl = r.Header().Get("Location") + return resp, err +} + +func (c *Client) UpdateCertificate(certificateId string, req *UpdateCertificateRequest) (*UpdateCertificateResponse, error) { + resp := &UpdateCertificateResponse{} + r, err := c.client.SendRequestWithResult(http.MethodPatch, fmt.Sprintf("/cdn/certificates/%s", url.PathEscape(certificateId)), req, resp, func(r *resty.Request) { + r.SetHeader("x-cnc-timestamp", fmt.Sprintf("%d", req.Timestamp)) + }) + if err != nil { + return resp, err + } + + resp.CertificateUrl = r.Header().Get("Location") + return resp, err +} + +func (c *Client) GetHostnameDetail(hostname string) (*GetHostnameDetailResponse, error) { + resp := &GetHostnameDetailResponse{} + _, err := c.client.SendRequestWithResult(http.MethodGet, fmt.Sprintf("/cdn/hostnames/%s", url.PathEscape(hostname)), nil, resp) + return resp, err +} + +func (c *Client) CreateDeploymentTask(req *CreateDeploymentTaskRequest) (*CreateDeploymentTaskResponse, error) { + resp := &CreateDeploymentTaskResponse{} + r, err := c.client.SendRequestWithResult(http.MethodPost, "/cdn/deploymentTasks", req, resp) + if err != nil { + return resp, err + } + + resp.DeploymentTaskUrl = r.Header().Get("Location") + return resp, err +} + +func (c *Client) GetDeploymentTaskDetail(deploymentTaskId string) (*GetDeploymentTaskDetailResponse, error) { + resp := &GetDeploymentTaskDetailResponse{} + _, err := c.client.SendRequestWithResult(http.MethodGet, fmt.Sprintf("/cdn/deploymentTasks/%s", url.PathEscape(hostname)), nil, resp) + return resp, err +} diff --git a/internal/pkg/vendors/wangsu-sdk/cdn/client.go b/internal/pkg/vendors/wangsu-sdk/cdn/client.go new file mode 100644 index 00000000..e1831960 --- /dev/null +++ b/internal/pkg/vendors/wangsu-sdk/cdn/client.go @@ -0,0 +1,20 @@ +package cdn + +import ( + "time" + + "github.com/usual2970/certimate/internal/pkg/vendors/wangsu-sdk/openapi" +) + +type Client struct { + client *openapi.Client +} + +func NewClient(accessKey, secretKey string) *Client { + return &Client{client: openapi.NewClient(accessKey, secretKey)} +} + +func (c *Client) WithTimeout(timeout time.Duration) *Client { + c.client.WithTimeout(timeout) + return c +} diff --git a/internal/pkg/vendors/wangsu-sdk/cdn/models.go b/internal/pkg/vendors/wangsu-sdk/cdn/models.go new file mode 100644 index 00000000..0126418a --- /dev/null +++ b/internal/pkg/vendors/wangsu-sdk/cdn/models.go @@ -0,0 +1,107 @@ +package cdn + +import ( + "github.com/usual2970/certimate/internal/pkg/vendors/wangsu-sdk/openapi" +) + +type baseResponse struct { + RequestId *string `json:"-"` + Code *string `json:"code,omitempty"` + Message *string `json:"message,omitempty"` +} + +var _ openapi.Result = (*baseResponse)(nil) + +func (r *baseResponse) SetRequestId(requestId string) { + r.RequestId = &requestId +} + +type CertificateVersion struct { + Comments *string `json:"comments,omitempty"` + PrivateKey *string `json:"privateKey,omitempty"` + Certificate *string `json:"certificate,omitempty"` + ChainCert *string `json:"chainCert,omitempty"` + IdentificationInfo *CertificateVersionIdentificationInfo `json:"identificationInfo,omitempty"` +} + +type CertificateVersionIdentificationInfo struct { + Country *string `json:"country,omitempty"` + State *string `json:"state,omitempty"` + City *string `json:"city,omitempty"` + Company *string `json:"company,omitempty"` + Department *string `json:"department,omitempty"` + CommonName *string `json:"commonName,omitempty" required:"true"` + Email *string `json:"email,omitempty"` + SubjectAlternativeNames *[]string `json:"subjectAlternativeNames,omitempty" required:"true"` +} + +type CreateCertificateRequest struct { + Timestamp int64 `json:"-"` + Name *string `json:"name,omitempty" required:"true"` + Description *string `json:"description,omitempty"` + AutoRenew *string `json:"autoRenew,omitempty"` + ForceRenew *bool `json:"forceRenew,omitempty"` + NewVersion *CertificateVersion `json:"newVersion,omitempty" required:"true"` +} + +type CreateCertificateResponse struct { + baseResponse + CertificateUrl string `json:"-"` +} + +type UpdateCertificateRequest struct { + Timestamp int64 `json:"-"` + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + AutoRenew *string `json:"autoRenew,omitempty"` + ForceRenew *bool `json:"forceRenew,omitempty"` + NewVersion *CertificateVersion `json:"newVersion,omitempty" required:"true"` +} + +type UpdateCertificateResponse struct { + baseResponse + CertificateUrl string `json:"-"` +} + +type HostnameProperty struct { + PropertyId string `json:"propertyId"` + Version int32 `json:"version"` + CertificateId *string `json:"certificateId,omitempty"` +} + +type GetHostnameDetailResponse struct { + baseResponse + Hostname string `json:"hostname"` + PropertyInProduction *HostnameProperty `json:"propertyInProduction,omitempty"` + PropertyInStaging *HostnameProperty `json:"propertyInStaging,omitempty"` +} + +type DeploymentTaskAction struct { + Action *string `json:"action,omitempty" required:"true"` + PropertyId *string `json:"propertyId,omitempty"` + CertificateId *string `json:"certificateId,omitempty"` + Version *string `json:"version,omitempty"` +} + +type CreateDeploymentTaskRequest struct { + Name *string `json:"name,omitempty"` + Target *string `json:"target,omitempty" required:"true"` + Actions *[]DeploymentTaskAction `json:"actions,omitempty" required:"true"` + Webhook *string `json:"webhook,omitempty"` +} + +type CreateDeploymentTaskResponse struct { + baseResponse + DeploymentTaskUrl string `json:"-"` +} + +type GetDeploymentTaskDetailResponse struct { + baseResponse + Target string `json:"target"` + Actions []DeploymentTaskAction `json:"actions"` + Status string `json:"status"` + StatusDetails string `json:"statusDetails"` + SubmissionTime string `json:"submissionTime"` + FinishTime string `json:"finishTime"` + ApiRequestId string `json:"apiRequestId"` +} diff --git a/internal/pkg/vendors/wangsu-sdk/openapi/client.go b/internal/pkg/vendors/wangsu-sdk/openapi/client.go new file mode 100644 index 00000000..6492aba8 --- /dev/null +++ b/internal/pkg/vendors/wangsu-sdk/openapi/client.go @@ -0,0 +1,187 @@ +package openapi + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/go-resty/resty/v2" +) + +type Client struct { + accessKey string + secretKey string + + client *resty.Client +} + +type Result interface { + SetRequestId(requestId string) +} + +func NewClient(accessKey, secretKey string) *Client { + client := resty.New(). + SetBaseURL("https://open.chinanetcenter.com"). + SetHeader("Host", "open.chinanetcenter.com"). + SetHeader("Accept", "application/json"). + SetHeader("Content-Type", "application/json"). + SetPreRequestHook(func(c *resty.Client, req *http.Request) error { + // Step 1: Get request method + method := req.Method + method = strings.ToUpper(method) + + // Step 2: Get request path + path := "/" + if req.URL != nil { + path = req.URL.Path + } + + // Step 3: Get unencoded query string + queryString := "" + if method != http.MethodPost && req.URL != nil { + queryString = req.URL.RawQuery + + s, err := url.QueryUnescape(queryString) + if err != nil { + return err + } + + queryString = s + } + + // Step 4: Get canonical headers & signed headers + canonicalHeaders := "" + + "content-type:" + strings.TrimSpace(strings.ToLower(req.Header.Get("Content-Type"))) + "\n" + + "host:" + strings.TrimSpace(strings.ToLower(req.Header.Get("Host"))) + "\n" + signedHeaders := "content-type;host" + + // Step 5: Get request payload + payload := "" + if method != http.MethodGet && req.Body != nil { + reader, err := req.GetBody() + if err != nil { + return err + } + + defer reader.Close() + + payloadb, err := io.ReadAll(reader) + if err != nil { + return err + } + + payload = string(payloadb) + } + hashedPayload := sha256.Sum256([]byte(payload)) + hashedPayloadHex := strings.ToLower(hex.EncodeToString(hashedPayload[:])) + + // Step 6: Get timestamp + var reqtime time.Time + timestampString := req.Header.Get("x-cnc-timestamp") + if timestampString == "" { + reqtime = time.Now().UTC() + timestampString = fmt.Sprintf("%d", reqtime.Unix()) + } else { + timestamp, err := strconv.ParseInt(timestampString, 10, 64) + if err != nil { + return err + } + reqtime = time.Unix(timestamp, 0).UTC() + } + + // Step 7: Get canonical request string + canonicalRequest := fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n%s", method, path, queryString, canonicalHeaders, signedHeaders, hashedPayloadHex) + hashedCanonicalRequest := sha256.Sum256([]byte(canonicalRequest)) + hashedCanonicalRequestHex := strings.ToLower(hex.EncodeToString(hashedCanonicalRequest[:])) + + // Step 8: String to sign + const SignAlgorithmHeader = "CNC-HMAC-SHA256" + stringToSign := fmt.Sprintf("%s\n%s\n%s", SignAlgorithmHeader, timestampString, hashedCanonicalRequestHex) + hmac := hmac.New(sha256.New, []byte(secretKey)) + hmac.Write([]byte(stringToSign)) + sign := hmac.Sum(nil) + signHex := strings.ToLower(hex.EncodeToString(sign)) + + // Step 9: Add headers to request + req.Header.Set("x-cnc-accessKey", accessKey) + req.Header.Set("x-cnc-timestamp", timestampString) + req.Header.Set("x-cnc-auth-method", "AKSK") + req.Header.Set("Authorization", fmt.Sprintf("%s Credential=%s, SignedHeaders=%s, Signature=%s", SignAlgorithmHeader, accessKey, signedHeaders, signHex)) + req.Header.Set("Date", reqtime.Format("Mon, 02 Jan 2006 15:04:05 GMT")) + + return nil + }) + + return &Client{ + accessKey: accessKey, + secretKey: secretKey, + 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{}, configureReq ...func(req *resty.Request)) (*resty.Response, error) { + req := c.client.R() + req.Method = method + req.URL = path + if strings.EqualFold(method, http.MethodGet) { + qs := make(map[string]string) + if params != nil { + temp := make(map[string]any) + jsonb, _ := json.Marshal(params) + json.Unmarshal(jsonb, &temp) + for k, v := range temp { + if v != nil { + qs[k] = fmt.Sprintf("%v", v) + } + } + } + + req = req.SetQueryParams(qs) + } else { + req = req.SetBody(params) + } + + for _, fn := range configureReq { + fn(req) + } + + resp, err := req.Send() + if err != nil { + return resp, fmt.Errorf("wangsu api error: failed to send request: %w", err) + } else if resp.IsError() { + return resp, fmt.Errorf("wangsu api error: unexpected status code: %d, resp: %s", resp.StatusCode(), resp.Body()) + } + + return resp, nil +} + +func (c *Client) SendRequestWithResult(method string, path string, params interface{}, result Result, configureReq ...func(req *resty.Request)) (*resty.Response, error) { + resp, err := c.sendRequest(method, path, params, configureReq...) + if err != nil { + if resp != nil { + json.Unmarshal(resp.Body(), &result) + result.SetRequestId(resp.Header().Get("x-cnc-request-id")) + } + return resp, err + } + + if err := json.Unmarshal(resp.Body(), &result); err != nil { + return resp, fmt.Errorf("wangsu api error: failed to parse response: %w", err) + } + + result.SetRequestId(resp.Header().Get("x-cnc-request-id")) + return resp, nil +} diff --git a/migrations/1744192800_upgrade.go b/migrations/1744192800_upgrade.go new file mode 100644 index 00000000..83e83ee6 --- /dev/null +++ b/migrations/1744192800_upgrade.go @@ -0,0 +1,91 @@ +package migrations + +import ( + "github.com/pocketbase/pocketbase/core" + m "github.com/pocketbase/pocketbase/migrations" +) + +func init() { + m.Register(func(app core.App) error { + collection, err := app.FindCollectionByNameOrId("4yzbv8urny5ja1e") + if err != nil { + return err + } + + // update field + if err := collection.Fields.AddMarshaledJSONAt(2, []byte(`{ + "hidden": false, + "id": "hwy7m03o", + "maxSelect": 1, + "name": "provider", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "1panel", + "acmehttpreq", + "akamai", + "aliyun", + "aws", + "azure", + "baiducloud", + "baishan", + "baotapanel", + "byteplus", + "buypass", + "cachefly", + "cdnfly", + "cloudflare", + "cloudns", + "cmcccloud", + "ctcccloud", + "cucccloud", + "desec", + "dnsla", + "dogecloud", + "dynv6", + "edgio", + "fastly", + "gname", + "gcore", + "godaddy", + "goedge", + "googletrustservices", + "huaweicloud", + "jdcloud", + "k8s", + "letsencrypt", + "letsencryptstaging", + "local", + "namecheap", + "namedotcom", + "namesilo", + "ns1", + "porkbun", + "powerdns", + "qiniu", + "qingcloud", + "rainyun", + "safeline", + "ssh", + "sslcom", + "tencentcloud", + "ucloud", + "upyun", + "vercel", + "volcengine", + "wangsu", + "webhook", + "westcn", + "zerossl" + ] + }`)); err != nil { + return err + } + + return app.Save(collection) + }, func(app core.App) error { + return nil + }) +} diff --git a/ui/public/imgs/providers/wangsu.svg b/ui/public/imgs/providers/wangsu.svg new file mode 100644 index 00000000..276ec1cc --- /dev/null +++ b/ui/public/imgs/providers/wangsu.svg @@ -0,0 +1 @@ + diff --git a/ui/src/components/access/AccessForm.tsx b/ui/src/components/access/AccessForm.tsx index d5906434..bffb1f49 100644 --- a/ui/src/components/access/AccessForm.tsx +++ b/ui/src/components/access/AccessForm.tsx @@ -51,6 +51,7 @@ import AccessFormUCloudConfig from "./AccessFormUCloudConfig"; import AccessFormUpyunConfig from "./AccessFormUpyunConfig"; import AccessFormVercelConfig from "./AccessFormVercelConfig"; import AccessFormVolcEngineConfig from "./AccessFormVolcEngineConfig"; +import AccessFormWangsuConfig from "./AccessFormWangsuConfig"; import AccessFormWebhookConfig from "./AccessFormWebhookConfig"; import AccessFormWestcnConfig from "./AccessFormWestcnConfig"; import AccessFormZeroSSLConfig from "./AccessFormZeroSSLConfig"; @@ -229,6 +230,8 @@ const AccessForm = forwardRef(({ className, return ; case ACCESS_PROVIDERS.VOLCENGINE: return ; + case ACCESS_PROVIDERS.WANGSU: + return ; case ACCESS_PROVIDERS.WEBHOOK: return ; case ACCESS_PROVIDERS.WESTCN: diff --git a/ui/src/components/access/AccessFormWangsuConfig.tsx b/ui/src/components/access/AccessFormWangsuConfig.tsx new file mode 100644 index 00000000..f9676829 --- /dev/null +++ b/ui/src/components/access/AccessFormWangsuConfig.tsx @@ -0,0 +1,76 @@ +import { useTranslation } from "react-i18next"; +import { Form, type FormInstance, Input } from "antd"; +import { createSchemaFieldRule } from "antd-zod"; +import { z } from "zod"; + +import { type AccessConfigForWangsu } from "@/domain/access"; + +type AccessFormWangsuConfigFieldValues = Nullish; + +export type AccessFormWangsuConfigProps = { + form: FormInstance; + formName: string; + disabled?: boolean; + initialValues?: AccessFormWangsuConfigFieldValues; + onValuesChange?: (values: AccessFormWangsuConfigFieldValues) => void; +}; + +const initFormModel = (): AccessFormWangsuConfigFieldValues => { + return { + accessKeyId: "", + accessKeySecret: "", + }; +}; + +const AccessFormWangsuConfig = ({ form: formInst, formName, disabled, initialValues, onValuesChange: onValuesChange }: AccessFormWangsuConfigProps) => { + const { t } = useTranslation(); + + const formSchema = z.object({ + accessKeyId: z + .string() + .min(1, t("access.form.wangsu_access_key_id.placeholder")) + .max(64, t("common.errmsg.string_max", { max: 64 })) + .trim(), + accessKeySecret: z + .string() + .min(1, t("access.form.wangsu_access_key_secret.placeholder")) + .max(64, t("common.errmsg.string_max", { max: 64 })) + .trim(), + }); + const formRule = createSchemaFieldRule(formSchema); + + const handleFormChange = (_: unknown, values: z.infer) => { + onValuesChange?.(values); + }; + + return ( +
+ } + > + + + + } + > + + +
+ ); +}; + +export default AccessFormWangsuConfig; diff --git a/ui/src/components/workflow/node/DeployNodeConfigForm.tsx b/ui/src/components/workflow/node/DeployNodeConfigForm.tsx index cd1a0fe7..5565c985 100644 --- a/ui/src/components/workflow/node/DeployNodeConfigForm.tsx +++ b/ui/src/components/workflow/node/DeployNodeConfigForm.tsx @@ -81,6 +81,7 @@ import DeployNodeConfigFormVolcEngineDCDNConfig from "./DeployNodeConfigFormVolc import DeployNodeConfigFormVolcEngineImageXConfig from "./DeployNodeConfigFormVolcEngineImageXConfig.tsx"; import DeployNodeConfigFormVolcEngineLiveConfig from "./DeployNodeConfigFormVolcEngineLiveConfig.tsx"; import DeployNodeConfigFormVolcEngineTOSConfig from "./DeployNodeConfigFormVolcEngineTOSConfig.tsx"; +import DeployNodeConfigFormWangsuCDNProConfig from "./DeployNodeConfigFormWangsuCDNProConfig.tsx"; import DeployNodeConfigFormWebhookConfig from "./DeployNodeConfigFormWebhookConfig.tsx"; type DeployNodeConfigFormFieldValues = Partial; @@ -302,6 +303,8 @@ const DeployNodeConfigForm = forwardRef; case DEPLOY_PROVIDERS.VOLCENGINE_TOS: return ; + case DEPLOY_PROVIDERS.WANGSU_CDNPRO: + return ; case DEPLOY_PROVIDERS.WEBHOOK: return ; } diff --git a/ui/src/components/workflow/node/DeployNodeConfigFormWangsuCDNProConfig.tsx b/ui/src/components/workflow/node/DeployNodeConfigFormWangsuCDNProConfig.tsx new file mode 100644 index 00000000..90bdb064 --- /dev/null +++ b/ui/src/components/workflow/node/DeployNodeConfigFormWangsuCDNProConfig.tsx @@ -0,0 +1,107 @@ +import { useTranslation } from "react-i18next"; +import { Form, type FormInstance, Input, Select } from "antd"; +import { createSchemaFieldRule } from "antd-zod"; +import { z } from "zod"; + +import { validDomainName } from "@/utils/validators"; + +type DeployNodeConfigFormBaishanCDNConfigFieldValues = Nullish<{ + environment: string; + domain: string; + certificateId?: string; + webhookId?: string; +}>; + +export type DeployNodeConfigFormBaishanCDNConfigProps = { + form: FormInstance; + formName: string; + disabled?: boolean; + initialValues?: DeployNodeConfigFormBaishanCDNConfigFieldValues; + onValuesChange?: (values: DeployNodeConfigFormBaishanCDNConfigFieldValues) => void; +}; + +const ENVIRONMENT_PRODUCTION = "production" as const; +const ENVIRONMENT_STAGING = "stating" as const; + +const initFormModel = (): DeployNodeConfigFormBaishanCDNConfigFieldValues => { + return { + environment: ENVIRONMENT_PRODUCTION, + }; +}; + +const DeployNodeConfigFormBaishanCDNConfig = ({ + form: formInst, + formName, + disabled, + initialValues, + onValuesChange, +}: DeployNodeConfigFormBaishanCDNConfigProps) => { + const { t } = useTranslation(); + + const formSchema = z.object({ + resourceType: z.union([z.literal(ENVIRONMENT_PRODUCTION), z.literal(ENVIRONMENT_STAGING)], { + message: t("workflow_node.deploy.form.wangsu_cdnpro_environment.placeholder"), + }), + domain: z + .string({ message: t("workflow_node.deploy.form.wangsu_cdnpro_domain.placeholder") }) + .refine((v) => validDomainName(v, { allowWildcard: true }), t("common.errmsg.domain_invalid")), + certificateId: z.string().nullish(), + webhookId: z.string().nullish(), + }); + const formRule = createSchemaFieldRule(formSchema); + + const handleFormChange = (_: unknown, values: z.infer) => { + onValuesChange?.(values); + }; + + return ( +
+ + + + + } + > + + + + } + > + + + + } + > + + +
+ ); +}; + +export default DeployNodeConfigFormBaishanCDNConfig; diff --git a/ui/src/domain/access.ts b/ui/src/domain/access.ts index 86d644fa..3015e4d0 100644 --- a/ui/src/domain/access.ts +++ b/ui/src/domain/access.ts @@ -47,6 +47,7 @@ export interface AccessModel extends BaseModel { | AccessConfigForUpyun | AccessConfigForVercel | AccessConfigForVolcEngine + | AccessConfigForWangsu | AccessConfigForWebhook | AccessConfigForWestcn | AccessConfigForZeroSSL @@ -268,6 +269,11 @@ export type AccessConfigForVolcEngine = { secretAccessKey: string; }; +export type AccessConfigForWangsu = { + accessKeyId: string; + accessKeySecret: string; +}; + export type AccessConfigForWebhook = { url: string; allowInsecureConnections?: boolean; diff --git a/ui/src/domain/provider.ts b/ui/src/domain/provider.ts index c101b0e3..9c59a30e 100644 --- a/ui/src/domain/provider.ts +++ b/ui/src/domain/provider.ts @@ -50,6 +50,7 @@ export const ACCESS_PROVIDERS = Object.freeze({ UPYUN: "upyun", VERCEL: "vercel", VOLCENGINE: "volcengine", + WANGSU: "wangsu", WEBHOOK: "webhook", WESTCN: "westcn", ZEROSSL: "zerossl", @@ -99,6 +100,7 @@ export const accessProvidersMap: Maphttps://www.volcengine.com/docs/6291/216571", + "access.form.wangsu_access_key_id.label": "Wangsu Cloud AccessKeyId", + "access.form.wangsu_access_key_id.placeholder": "Please enter Wangsu Cloud AccessKeyId", + "access.form.wangsu_access_key_id.tooltip": "For more information, see https://en.wangsu.com/document/account-manage/15775", + "access.form.wangsu_access_key_secret.label": "Wangsu Cloud AccessKeySecret", + "access.form.wangsu_access_key_secret.placeholder": "Please enter Wangsu Cloud AccessKeySecret", + "access.form.wangsu_access_key_secret.tooltip": "For more information, see https://en.wangsu.com/document/account-manage/15775", "access.form.webhook_url.label": "Webhook URL", "access.form.webhook_url.placeholder": "Please enter Webhook URL", "access.form.webhook_allow_insecure_conns.label": "Insecure SSL/TLS connections", diff --git a/ui/src/i18n/locales/en/nls.provider.json b/ui/src/i18n/locales/en/nls.provider.json index 5df6c3b3..76680957 100644 --- a/ui/src/i18n/locales/en/nls.provider.json +++ b/ui/src/i18n/locales/en/nls.provider.json @@ -125,6 +125,8 @@ "provider.volcengine.imagex": "Volcengine - ImageX", "provider.volcengine.live": "Volcengine - Live", "provider.volcengine.tos": "Volcengine - TOS (Tinder Object Storage)", + "provider.wangsu": "Wangsu Cloud", + "provider.wangsu.cdnpro": "Wangsu Cloud - CDN Pro", "provider.webhook": "Webhook", "provider.westcn": "West.cn", "provider.zerossl": "ZeroSSL", diff --git a/ui/src/i18n/locales/en/nls.workflow.nodes.json b/ui/src/i18n/locales/en/nls.workflow.nodes.json index d6728544..4ac796b3 100644 --- a/ui/src/i18n/locales/en/nls.workflow.nodes.json +++ b/ui/src/i18n/locales/en/nls.workflow.nodes.json @@ -95,7 +95,7 @@ "workflow_node.deploy.form.provider.placeholder": "Please select deploy target", "workflow_node.deploy.form.provider_access.label": "Host provider authorization", "workflow_node.deploy.form.provider_access.placeholder": "Please select an authorization of host provider", - "workflow_node.deploy.form.provider_access.tooltip": "Used to deploy certificates.", + "workflow_node.deploy.form.provider_access.tooltip": "Used to invoke API during deployment.", "workflow_node.deploy.form.provider_access.button": "Create", "workflow_node.deploy.form.provider_access.guide_for_local": "Tips: If you are running Certimate in Docker, the \"Local\" refers to the container rather than the host.", "workflow_node.deploy.form.certificate.label": "Certificate", @@ -269,11 +269,11 @@ "workflow_node.deploy.form.baiducloud_cdn_domain.label": "Baidu Cloud CDN domain", "workflow_node.deploy.form.baiducloud_cdn_domain.placeholder": "Please enter Baidu Cloud CDN domain name", "workflow_node.deploy.form.baiducloud_cdn_domain.tooltip": "For more information, see https://console.bce.baidu.com/cdn", - "workflow_node.deploy.form.baishan_cdn_domain.label": "Baishan CDN domain", - "workflow_node.deploy.form.baishan_cdn_domain.placeholder": "Please enter Baishan CDN domain name", + "workflow_node.deploy.form.baishan_cdn_domain.label": "Baishan Cloud CDN domain", + "workflow_node.deploy.form.baishan_cdn_domain.placeholder": "Please enter Baishan Cloud CDN domain name", "workflow_node.deploy.form.baishan_cdn_domain.tooltip": "For more information, see https://cdnx.console.baishan.com", - "workflow_node.deploy.form.baishan_cdn_certificate_id.label": "Baishan CDN certificate ID (Optional", - "workflow_node.deploy.form.baishan_cdn_certificate_id.placeholder": "Please enter Baishan CDN certificate ID", + "workflow_node.deploy.form.baishan_cdn_certificate_id.label": "Baishan Cloud CDN certificate ID (Optional)", + "workflow_node.deploy.form.baishan_cdn_certificate_id.placeholder": "Please enter Baishan Cloud CDN certificate ID", "workflow_node.deploy.form.baishan_cdn_certificate_id.tooltip": "For more information, see https://cdnx.console.baishan.com/#/cdn/cert", "workflow_node.deploy.form.baotapanel_console_auto_restart.label": "Auto restart after deployment", "workflow_node.deploy.form.baotapanel_site_type.label": "aaPanel site type", @@ -639,6 +639,19 @@ "workflow_node.deploy.form.volcengine_tos_domain.label": "VolcEngine TOS domain", "workflow_node.deploy.form.volcengine_tos_domain.placeholder": "Please enter VolcEngine TOS domain name", "workflow_node.deploy.form.volcengine_tos_domain.tooltip": "For more information, see https://console.volcengine.com/tos", + "workflow_node.deploy.form.wangsu_cdnpro_environment.label": "Wangsu Cloud environment", + "workflow_node.deploy.form.wangsu_cdnpro_environment.placeholder": "Please select Wangsu Cloud environment", + "workflow_node.deploy.form.wangsu_cdnpro_environment.option.production.label": "Production environment", + "workflow_node.deploy.form.wangsu_cdnpro_environment.option.staging.label": "Staging environment", + "workflow_node.deploy.form.wangsu_cdnpro_domain.label": "Wangsu Cloud CDN domain", + "workflow_node.deploy.form.wangsu_cdnpro_domain.placeholder": "Please enter Wangsu Cloud CDN domain name", + "workflow_node.deploy.form.wangsu_cdnpro_domain.tooltip": "For more information, see https://cdnpro.console.wangsu.com/v2/index/#/properties", + "workflow_node.deploy.form.wangsu_cdnpro_certificate_id.label": "Wangsu Cloud CDN certificate ID (Optional)", + "workflow_node.deploy.form.wangsu_cdnpro_certificate_id.placeholder": "Please enter Wangsu Cloud CDN certificate ID", + "workflow_node.deploy.form.wangsu_cdnpro_certificate_id.tooltip": "For more information, see https://cdnpro.console.wangsu.com/v2/index/#/certificate", + "workflow_node.deploy.form.wangsu_cdnpro_webhook_id.label": "Wangsu Cloud CDN Webhook ID (Optional)", + "workflow_node.deploy.form.wangsu_cdnpro_webhook_id.placeholder": "Please enter Wangsu Cloud CDN Webhook ID", + "workflow_node.deploy.form.wangsu_cdnpro_webhook_id.tooltip": "For more information, see https://cdnpro.console.wangsu.com/v2/index/#/certificate", "workflow_node.deploy.form.webhook_data.label": "Webhook data (JSON format)", "workflow_node.deploy.form.webhook_data.placeholder": "Please enter Webhook data", "workflow_node.deploy.form.webhook_data.guide": "Tips: The Webhook data should be a key-value pair in JSON format. The values in JSON support template variables, which will be replaced by actual values when sent to the Webhook URL.

Supported variables:
${DOMAIN}: The primary domain of the certificate (CommonName).
${DOMAINS}: The domain list of the certificate (SubjectAltNames).
${CERTIFICATE}: The PEM format content of the certificate file.
${PRIVATE_KEY}: The PEM format content of the private key file.", diff --git a/ui/src/i18n/locales/zh/nls.access.json b/ui/src/i18n/locales/zh/nls.access.json index 6022fa15..bf068260 100644 --- a/ui/src/i18n/locales/zh/nls.access.json +++ b/ui/src/i18n/locales/zh/nls.access.json @@ -298,6 +298,12 @@ "access.form.volcengine_secret_access_key.label": "火山引擎 SecretAccessKey", "access.form.volcengine_secret_access_key.placeholder": "请输入火山引擎 SecretAccessKey", "access.form.volcengine_secret_access_key.tooltip": "这是什么?请参阅 https://www.volcengine.com/docs/6291/216571", + "access.form.wangsu_access_key_id.label": "网宿云 AccessKeyId", + "access.form.wangsu_access_key_id.placeholder": "请输入网宿科技 AccessKeyId", + "access.form.wangsu_access_key_id.tooltip": "这是什么?请参阅 https://www.wangsu.com/document/account-manage/15775", + "access.form.wangsu_access_key_secret.label": "网宿科技 AccessKeySecret", + "access.form.wangsu_access_key_secret.placeholder": "请输入网宿科技 AccessKeySecret", + "access.form.wangsu_access_key_secret.tooltip": "这是什么?请参阅 https://www.wangsu.com/document/account-manage/15775", "access.form.webhook_url.label": "Webhook 回调地址", "access.form.webhook_url.placeholder": "请输入 Webhook 回调地址", "access.form.webhook_allow_insecure_conns.label": "忽略 SSL/TLS 证书错误", diff --git a/ui/src/i18n/locales/zh/nls.provider.json b/ui/src/i18n/locales/zh/nls.provider.json index 34ef3fb2..d6be56a2 100644 --- a/ui/src/i18n/locales/zh/nls.provider.json +++ b/ui/src/i18n/locales/zh/nls.provider.json @@ -125,6 +125,8 @@ "provider.volcengine.imagex": "火山引擎 - 图片服务 ImageX", "provider.volcengine.live": "火山引擎 - 视频直播 Live", "provider.volcengine.tos": "火山引擎 - 对象存储 TOS", + "provider.wangsu": "网宿云", + "provider.wangsu.cdnpro": "网宿云 - CDN Pro", "provider.webhook": "Webhook", "provider.westcn": "西部数码", "provider.zerossl": "ZeroSSL", diff --git a/ui/src/i18n/locales/zh/nls.workflow.nodes.json b/ui/src/i18n/locales/zh/nls.workflow.nodes.json index 7e50cc5b..5fcb201d 100644 --- a/ui/src/i18n/locales/zh/nls.workflow.nodes.json +++ b/ui/src/i18n/locales/zh/nls.workflow.nodes.json @@ -94,7 +94,7 @@ "workflow_node.deploy.form.provider.placeholder": "请选择部署目标", "workflow_node.deploy.form.provider_access.label": "主机提供商授权", "workflow_node.deploy.form.provider_access.placeholder": "请选择主机提供商授权", - "workflow_node.deploy.form.provider_access.tooltip": "用于部署证书,注意与申请阶段所需的 DNS 提供商相区分。", + "workflow_node.deploy.form.provider_access.tooltip": "用于部署证书时调用相关 API,注意与申请阶段所需的 DNS 提供商相区分。", "workflow_node.deploy.form.provider_access.button": "新建", "workflow_node.deploy.form.provider_access.guide_for_local": "小贴士:如果你正在使用 Docker 运行 Certimate,“本地”指的是容器内而非宿主机。", "workflow_node.deploy.form.certificate.label": "待部署证书", @@ -638,6 +638,19 @@ "workflow_node.deploy.form.volcengine_tos_domain.label": "火山引擎 TOS 自定义域名", "workflow_node.deploy.form.volcengine_tos_domain.placeholder": "请输入火山引擎 TOS 自定义域名", "workflow_node.deploy.form.volcengine_tos_domain.tooltip": "这是什么?请参阅 see https://console.volcengine.com/tos", + "workflow_node.deploy.form.wangsu_cdnpro_environment.label": "网宿云环境", + "workflow_node.deploy.form.wangsu_cdnpro_environment.placeholder": "请选择网宿云环境", + "workflow_node.deploy.form.wangsu_cdnpro_environment.option.production.label": "生产环境", + "workflow_node.deploy.form.wangsu_cdnpro_environment.option.staging.label": "演练环境", + "workflow_node.deploy.form.wangsu_cdnpro_domain.label": "网宿云 CDN Pro 加速域名", + "workflow_node.deploy.form.wangsu_cdnpro_domain.placeholder": "请输入网宿云 CDN Pro 加速域名(支持泛域名)", + "workflow_node.deploy.form.wangsu_cdnpro_domain.tooltip": "这是什么?请参阅 https://cdnpro.console.wangsu.com/v2/index/#/properties", + "workflow_node.deploy.form.wangsu_cdnpro_certificate_id.label": "网宿云 CDN Pro 原证书 ID(可选)", + "workflow_node.deploy.form.wangsu_cdnpro_certificate_id.placeholder": "请输入网宿云 CDN Pro 原证书 ID", + "workflow_node.deploy.form.wangsu_cdnpro_certificate_id.tooltip": "这是什么?请参阅 https://cdnpro.console.wangsu.com/v2/index/#/certificate

不填写时,将上传新证书;否则,将替换原证书。", + "workflow_node.deploy.form.wangsu_cdnpro_webhook_id.label": "网宿云 CDN Pro 部署任务 Webhook ID(可选)", + "workflow_node.deploy.form.wangsu_cdnpro_webhook_id.placeholder": "请输入网宿云 CDN Pro 部署任务 Webhook ID", + "workflow_node.deploy.form.wangsu_cdnpro_webhook_id.tooltip": "这是什么?请参阅 https://cdnpro.console.wangsu.com/v2/index/#/certificate", "workflow_node.deploy.form.webhook_data.label": "Webhook 回调数据(JSON 格式)", "workflow_node.deploy.form.webhook_data.placeholder": "请输入 Webhook 回调数据", "workflow_node.deploy.form.webhook_data.guide": "小贴士:回调数据是一个 JSON 格式的键值对。其中值支持模板变量,将在被发送到指定的 Webhook URL 时被替换为实际值;其他内容将保持原样。

支持的变量:
${DOMAIN}:证书的主域名(即 CommonName
${DOMAINS}:证书的多域名列表(即 SubjectAltNames
${CERTIFICATE}:证书文件 PEM 格式内容
${PRIVATE_KEY}:私钥文件 PEM 格式内容",