diff --git a/README.md b/README.md index 0e32d29e..18efb6f1 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Certimate 旨在为用户提供一个安全、简便的 SSL 证书管理解决 - 支持 20+ 域名托管商(如阿里云、腾讯云、Cloudflare 等,[点此查看完整清单](https://docs.certimate.me/docs/reference/providers#supported-dns-providers)); - 支持 70+ 部署目标(如 Kubernetes、CDN、WAF、负载均衡等,[点此查看完整清单](https://docs.certimate.me/docs/reference/providers#supported-host-providers)); - 支持邮件、钉钉、飞书、企业微信、Webhook 等多种通知渠道; -- 支持 Let's Encrypt、ZeroSSL、Google Trust Services 等多种 ACME 证书颁发机构; +- 支持 Let's Encrypt、Buypass、Google Trust Services、SSL.com、ZeroSSL 等多种 ACME 证书颁发机构; - 更多特性等待探索。 ## ⏱️ 快速启动 diff --git a/README_EN.md b/README_EN.md index 55999bfc..5b62f12d 100644 --- a/README_EN.md +++ b/README_EN.md @@ -41,7 +41,7 @@ Certimate aims to provide users with a secure and user-friendly SSL certificate - Supports more than 20+ domain registrars (e.g., Alibaba Cloud, Tencent Cloud, Cloudflare, etc. [Check out this link](https://docs.certimate.me/en/docs/reference/providers#supported-dns-providers)); - Supports more than 70+ deployment targets (e.g., Kubernetes, CDN, WAF, load balancers, etc. [Check out this link](https://docs.certimate.me/en/docs/reference/providers#supported-host-providers)); - Supports multiple notification channels including email, DingTalk, Feishu, WeCom, Webhook, and more; -- Supports multiple ACME CAs including Let's Encrypt, ZeroSSL, Google Trust Services, and more; +- Supports multiple ACME CAs including Let's Encrypt, Buypass, Google Trust Services,SSL.com, ZeroSSL, and more; - More features waiting to be discovered. ## ⏱️ Fast Track diff --git a/internal/applicant/acme_ca.go b/internal/applicant/acme_ca.go index 57f24b6e..03d9dd65 100644 --- a/internal/applicant/acme_ca.go +++ b/internal/applicant/acme_ca.go @@ -1,38 +1,30 @@ package applicant -const ( - sslProviderLetsEncrypt = "letsencrypt" - sslProviderLetsEncryptStaging = "letsencrypt_staging" - sslProviderZeroSSL = "zerossl" - sslProviderGoogleTrustServices = "gts" -) -const defaultSSLProvider = sslProviderLetsEncrypt +import "github.com/usual2970/certimate/internal/domain" const ( - letsencryptUrl = "https://acme-v02.api.letsencrypt.org/directory" - letsencryptStagingUrl = "https://acme-staging-v02.api.letsencrypt.org/directory" - zerosslUrl = "https://acme.zerossl.com/v2/DV90" - gtsUrl = "https://dv.acme-v02.api.pki.goog/directory" + sslProviderLetsEncrypt = string(domain.ApplyCAProviderTypeLetsEncrypt) + sslProviderLetsEncryptStaging = string(domain.ApplyCAProviderTypeLetsEncryptStaging) + sslProviderBuypass = string(domain.ApplyCAProviderTypeBuypass) + sslProviderGoogleTrustServices = string(domain.ApplyCAProviderTypeGoogleTrustServices) + sslProviderSSLCom = string(domain.ApplyCAProviderTypeSSLCom) + sslProviderZeroSSL = string(domain.ApplyCAProviderTypeZeroSSL) + + sslProviderDefault = sslProviderLetsEncrypt ) var sslProviderUrls = map[string]string{ - sslProviderLetsEncrypt: letsencryptUrl, - sslProviderLetsEncryptStaging: letsencryptStagingUrl, - sslProviderZeroSSL: zerosslUrl, - sslProviderGoogleTrustServices: gtsUrl, + sslProviderLetsEncrypt: "https://acme-v02.api.letsencrypt.org/directory", + sslProviderLetsEncryptStaging: "https://acme-staging-v02.api.letsencrypt.org/directory", + sslProviderBuypass: "https://api.buypass.com/acme/directory", + sslProviderGoogleTrustServices: "https://dv.acme-v02.api.pki.goog/directory", + sslProviderSSLCom: "https://acme.ssl.com/sslcom-dv-rsa", + sslProviderSSLCom + "RSA": "https://acme.ssl.com/sslcom-dv-rsa", + sslProviderSSLCom + "ECC": "https://acme.ssl.com/sslcom-dv-ecc", + sslProviderZeroSSL: "https://acme.zerossl.com/v2/DV90", } type acmeSSLProviderConfig struct { - Config acmeSSLProviderConfigContent `json:"config"` - Provider string `json:"provider"` -} - -type acmeSSLProviderConfigContent struct { - ZeroSSL acmeSSLProviderEabConfig `json:"zerossl"` - GoogleTrustServices acmeSSLProviderEabConfig `json:"gts"` -} - -type acmeSSLProviderEabConfig struct { - EabHmacKey string `json:"eabHmacKey"` - EabKid string `json:"eabKid"` + Config map[domain.ApplyCAProviderType]map[string]any `json:"config"` + Provider string `json:"provider"` } diff --git a/internal/applicant/acme_user.go b/internal/applicant/acme_user.go index 107e417c..430db5a3 100644 --- a/internal/applicant/acme_user.go +++ b/internal/applicant/acme_user.go @@ -14,6 +14,7 @@ import ( "github.com/usual2970/certimate/internal/domain" "github.com/usual2970/certimate/internal/pkg/utils/certutil" + "github.com/usual2970/certimate/internal/pkg/utils/maputil" "github.com/usual2970/certimate/internal/repository" ) @@ -76,16 +77,11 @@ func (u *acmeUser) getPrivateKeyPEM() string { return u.privkey } -type acmeAccountRepository interface { - GetByCAAndEmail(ca, email string) (*domain.AcmeAccount, error) - Save(ca, email, key string, resource *registration.Resource) error -} - var registerGroup singleflight.Group -func registerAcmeUserWithSingleFlight(client *lego.Client, sslProviderConfig *acmeSSLProviderConfig, user *acmeUser) (*registration.Resource, error) { - resp, err, _ := registerGroup.Do(fmt.Sprintf("register_acme_user_%s_%s", sslProviderConfig.Provider, user.GetEmail()), func() (interface{}, error) { - return registerAcmeUser(client, sslProviderConfig, user) +func registerAcmeUserWithSingleFlight(client *lego.Client, user *acmeUser, userRegisterOptions map[string]any) (*registration.Resource, error) { + resp, err, _ := registerGroup.Do(fmt.Sprintf("register_acme_user_%s_%s", user.CA, user.Email), func() (interface{}, error) { + return registerAcmeUser(client, user, userRegisterOptions) }) if err != nil { @@ -95,45 +91,81 @@ func registerAcmeUserWithSingleFlight(client *lego.Client, sslProviderConfig *ac return resp.(*registration.Resource), nil } -func registerAcmeUser(client *lego.Client, sslProviderConfig *acmeSSLProviderConfig, user *acmeUser) (*registration.Resource, error) { +func registerAcmeUser(client *lego.Client, user *acmeUser, userRegisterOptions map[string]any) (*registration.Resource, error) { var reg *registration.Resource var err error - switch sslProviderConfig.Provider { - case sslProviderZeroSSL: - reg, err = client.Registration.RegisterWithExternalAccountBinding(registration.RegisterEABOptions{ - TermsOfServiceAgreed: true, - Kid: sslProviderConfig.Config.ZeroSSL.EabKid, - HmacEncoded: sslProviderConfig.Config.ZeroSSL.EabHmacKey, - }) - case sslProviderGoogleTrustServices: - reg, err = client.Registration.RegisterWithExternalAccountBinding(registration.RegisterEABOptions{ - TermsOfServiceAgreed: true, - Kid: sslProviderConfig.Config.GoogleTrustServices.EabKid, - HmacEncoded: sslProviderConfig.Config.GoogleTrustServices.EabHmacKey, - }) + switch user.CA { case sslProviderLetsEncrypt, sslProviderLetsEncryptStaging: reg, err = client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) + + case sslProviderBuypass: + { + reg, err = client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) + } + + case sslProviderGoogleTrustServices: + { + access := domain.AccessConfigForGoogleTrustServices{} + if err := maputil.Populate(userRegisterOptions, &access); err != nil { + return nil, fmt.Errorf("failed to populate provider access config: %w", err) + } + + reg, err = client.Registration.RegisterWithExternalAccountBinding(registration.RegisterEABOptions{ + TermsOfServiceAgreed: true, + Kid: access.EabKid, + HmacEncoded: access.EabHmacKey, + }) + } + + case sslProviderSSLCom: + { + access := domain.AccessConfigForSSLCom{} + if err := maputil.Populate(userRegisterOptions, &access); err != nil { + return nil, fmt.Errorf("failed to populate provider access config: %w", err) + } + + reg, err = client.Registration.RegisterWithExternalAccountBinding(registration.RegisterEABOptions{ + TermsOfServiceAgreed: true, + Kid: access.EabKid, + HmacEncoded: access.EabHmacKey, + }) + } + + case sslProviderZeroSSL: + { + access := domain.AccessConfigForZeroSSL{} + if err := maputil.Populate(userRegisterOptions, &access); err != nil { + return nil, fmt.Errorf("failed to populate provider access config: %w", err) + } + + reg, err = client.Registration.RegisterWithExternalAccountBinding(registration.RegisterEABOptions{ + TermsOfServiceAgreed: true, + Kid: access.EabKid, + HmacEncoded: access.EabHmacKey, + }) + } + default: - err = fmt.Errorf("unsupported ssl provider: %s", sslProviderConfig.Provider) + err = fmt.Errorf("unsupported ca provider: %s", user.CA) } if err != nil { return nil, err } repo := repository.NewAcmeAccountRepository() - resp, err := repo.GetByCAAndEmail(sslProviderConfig.Provider, user.GetEmail()) + resp, err := repo.GetByCAAndEmail(user.CA, user.Email) if err == nil { user.privkey = resp.Key return resp.Resource, nil } if _, err := repo.Save(context.Background(), &domain.AcmeAccount{ - CA: sslProviderConfig.Provider, - Email: user.GetEmail(), + CA: user.CA, + Email: user.Email, Key: user.getPrivateKeyPEM(), Resource: reg, }); err != nil { - return nil, fmt.Errorf("failed to save registration: %w", err) + return nil, fmt.Errorf("failed to save acme account registration: %w", err) } return reg, nil diff --git a/internal/applicant/applicant.go b/internal/applicant/applicant.go index 8cd8b6d0..93ab30ae 100644 --- a/internal/applicant/applicant.go +++ b/internal/applicant/applicant.go @@ -37,18 +37,21 @@ type Applicant interface { } type applicantOptions struct { - Domains []string - ContactEmail string - Provider domain.ApplyDNSProviderType - ProviderAccessConfig map[string]any - ProviderApplyConfig map[string]any - KeyAlgorithm string - Nameservers []string - DnsPropagationTimeout int32 - DnsTTL int32 - DisableFollowCNAME bool - ReplacedARIAcctId string - ReplacedARICertId string + Domains []string + ContactEmail string + Provider domain.ApplyDNSProviderType + ProviderAccessConfig map[string]any + ProviderExtendedConfig map[string]any + CAProvider domain.ApplyCAProviderType + CAProviderAccessConfig map[string]any + CAProviderExtendedConfig map[string]any + KeyAlgorithm string + Nameservers []string + DnsPropagationTimeout int32 + DnsTTL int32 + DisableFollowCNAME bool + ReplacedARIAcct string + ReplacedARICert string } func NewWithApplyNode(node *domain.WorkflowNode) (Applicant, error) { @@ -58,22 +61,55 @@ func NewWithApplyNode(node *domain.WorkflowNode) (Applicant, error) { nodeConfig := node.GetConfigForApply() options := &applicantOptions{ - Domains: sliceutil.Filter(strings.Split(nodeConfig.Domains, ";"), func(s string) bool { return s != "" }), - ContactEmail: nodeConfig.ContactEmail, - Provider: domain.ApplyDNSProviderType(nodeConfig.Provider), - ProviderApplyConfig: nodeConfig.ProviderConfig, - KeyAlgorithm: nodeConfig.KeyAlgorithm, - Nameservers: sliceutil.Filter(strings.Split(nodeConfig.Nameservers, ";"), func(s string) bool { return s != "" }), - DnsPropagationTimeout: nodeConfig.DnsPropagationTimeout, - DnsTTL: nodeConfig.DnsTTL, - DisableFollowCNAME: nodeConfig.DisableFollowCNAME, + Domains: sliceutil.Filter(strings.Split(nodeConfig.Domains, ";"), func(s string) bool { return s != "" }), + ContactEmail: nodeConfig.ContactEmail, + Provider: domain.ApplyDNSProviderType(nodeConfig.Provider), + ProviderAccessConfig: make(map[string]any), + ProviderExtendedConfig: nodeConfig.ProviderConfig, + CAProvider: domain.ApplyCAProviderType(nodeConfig.CAProvider), + CAProviderAccessConfig: make(map[string]any), + CAProviderExtendedConfig: nodeConfig.CAProviderConfig, + KeyAlgorithm: nodeConfig.KeyAlgorithm, + Nameservers: sliceutil.Filter(strings.Split(nodeConfig.Nameservers, ";"), func(s string) bool { return s != "" }), + DnsPropagationTimeout: nodeConfig.DnsPropagationTimeout, + DnsTTL: nodeConfig.DnsTTL, + DisableFollowCNAME: nodeConfig.DisableFollowCNAME, } accessRepo := repository.NewAccessRepository() - if access, err := accessRepo.GetById(context.Background(), nodeConfig.ProviderAccessId); err != nil { - return nil, fmt.Errorf("failed to get access #%s record: %w", nodeConfig.ProviderAccessId, err) - } else { - options.ProviderAccessConfig = access.Config + if nodeConfig.ProviderAccessId != "" { + if access, err := accessRepo.GetById(context.Background(), nodeConfig.ProviderAccessId); err != nil { + return nil, fmt.Errorf("failed to get access #%s record: %w", nodeConfig.ProviderAccessId, err) + } else { + options.ProviderAccessConfig = access.Config + } + } + if nodeConfig.CAProviderAccessId != "" { + if access, err := accessRepo.GetById(context.Background(), nodeConfig.CAProviderAccessId); err != nil { + return nil, fmt.Errorf("failed to get access #%s record: %w", nodeConfig.CAProviderAccessId, err) + } else { + options.CAProviderAccessConfig = access.Config + } + } + + settingsRepo := repository.NewSettingsRepository() + if string(options.CAProvider) == "" { + settings, _ := settingsRepo.GetByName(context.Background(), "sslProvider") + + sslProviderConfig := &acmeSSLProviderConfig{ + Config: make(map[domain.ApplyCAProviderType]map[string]any), + Provider: sslProviderDefault, + } + if settings != nil { + if err := json.Unmarshal([]byte(settings.Content), sslProviderConfig); err != nil { + return nil, err + } else if sslProviderConfig.Provider == "" { + sslProviderConfig.Provider = sslProviderDefault + } + } + + options.CAProvider = domain.ApplyCAProviderType(sslProviderConfig.Provider) + options.CAProviderAccessConfig = sslProviderConfig.Config[options.CAProvider] } certRepo := repository.NewCertificateRepository() @@ -88,8 +124,8 @@ func NewWithApplyNode(node *domain.WorkflowNode) (Applicant, error) { lastCertX509, _ := certcrypto.ParsePEMCertificate([]byte(lastCertificate.Certificate)) if lastCertX509 != nil { replacedARICertId, _ := certificate.MakeARICertID(lastCertX509) - options.ReplacedARIAcctId = lastCertificate.ACMEAccountUrl - options.ReplacedARICertId = replacedARICertId + options.ReplacedARIAcct = lastCertificate.ACMEAccountUrl + options.ReplacedARICert = replacedARICertId } } } @@ -106,24 +142,7 @@ func NewWithApplyNode(node *domain.WorkflowNode) (Applicant, error) { } func apply(challengeProvider challenge.Provider, options *applicantOptions) (*ApplyCertResult, error) { - settingsRepo := repository.NewSettingsRepository() - settings, _ := settingsRepo.GetByName(context.Background(), "sslProvider") - - sslProviderConfig := &acmeSSLProviderConfig{ - Config: acmeSSLProviderConfigContent{}, - Provider: defaultSSLProvider, - } - if settings != nil { - if err := json.Unmarshal([]byte(settings.Content), sslProviderConfig); err != nil { - return nil, err - } - } - - if sslProviderConfig.Provider == "" { - sslProviderConfig.Provider = defaultSSLProvider - } - - acmeUser, err := newAcmeUser(sslProviderConfig.Provider, options.ContactEmail) + user, err := newAcmeUser(string(options.CAProvider), options.ContactEmail) if err != nil { return nil, err } @@ -133,9 +152,16 @@ func apply(challengeProvider challenge.Provider, options *applicantOptions) (*Ap os.Setenv("LEGO_DISABLE_CNAME_SUPPORT", strconv.FormatBool(options.DisableFollowCNAME)) // Create an ACME client config - config := lego.NewConfig(acmeUser) - config.CADirURL = sslProviderUrls[sslProviderConfig.Provider] + config := lego.NewConfig(user) config.Certificate.KeyType = parseKeyAlgorithm(domain.CertificateKeyAlgorithmType(options.KeyAlgorithm)) + config.CADirURL = sslProviderUrls[user.CA] + if user.CA == sslProviderSSLCom { + if strings.HasPrefix(options.KeyAlgorithm, "RSA") { + config.CADirURL = sslProviderUrls[sslProviderSSLCom+"RSA"] + } else if strings.HasPrefix(options.KeyAlgorithm, "EC") { + config.CADirURL = sslProviderUrls[sslProviderSSLCom+"ECC"] + } + } // Create an ACME client client, err := lego.NewClient(config) @@ -152,12 +178,12 @@ func apply(challengeProvider challenge.Provider, options *applicantOptions) (*Ap client.Challenge.SetDNS01Provider(challengeProvider, challengeOptions...) // New users need to register first - if !acmeUser.hasRegistration() { - reg, err := registerAcmeUserWithSingleFlight(client, sslProviderConfig, acmeUser) + if !user.hasRegistration() { + reg, err := registerAcmeUserWithSingleFlight(client, user, options.CAProviderAccessConfig) if err != nil { return nil, fmt.Errorf("failed to register: %w", err) } - acmeUser.Registration = reg + user.Registration = reg } // Obtain a certificate @@ -165,8 +191,8 @@ func apply(challengeProvider challenge.Provider, options *applicantOptions) (*Ap Domains: options.Domains, Bundle: true, } - if options.ReplacedARICertId != "" && options.ReplacedARIAcctId != acmeUser.Registration.URI { - certRequest.ReplacesCertID = options.ReplacedARICertId + if options.ReplacedARIAcct == user.Registration.URI { + certRequest.ReplacesCertID = options.ReplacedARICert } certResource, err := client.Certificate.Obtain(certRequest) if err != nil { @@ -177,7 +203,7 @@ func apply(challengeProvider challenge.Provider, options *applicantOptions) (*Ap CertificateFullChain: strings.TrimSpace(string(certResource.Certificate)), IssuerCertificate: strings.TrimSpace(string(certResource.IssuerCertificate)), PrivateKey: strings.TrimSpace(string(certResource.PrivateKey)), - ACMEAccountUrl: acmeUser.Registration.URI, + ACMEAccountUrl: user.Registration.URI, ACMECertUrl: certResource.CertURL, ACMECertStableUrl: certResource.CertStableURL, CSR: strings.TrimSpace(string(certResource.CSR)), @@ -198,6 +224,8 @@ func parseKeyAlgorithm(algo domain.CertificateKeyAlgorithmType) certcrypto.KeyTy return certcrypto.EC256 case domain.CertificateKeyAlgorithmTypeEC384: return certcrypto.EC384 + case domain.CertificateKeyAlgorithmTypeEC512: + return certcrypto.KeyType("P512") } return certcrypto.RSA2048 diff --git a/internal/applicant/providers.go b/internal/applicant/providers.go index a9c8a329..74a12678 100644 --- a/internal/applicant/providers.go +++ b/internal/applicant/providers.go @@ -86,8 +86,8 @@ func createApplicant(options *applicantOptions) (challenge.Provider, error) { applicant, err := pAWSRoute53.NewChallengeProvider(&pAWSRoute53.ChallengeProviderConfig{ AccessKeyId: access.AccessKeyId, SecretAccessKey: access.SecretAccessKey, - Region: maputil.GetString(options.ProviderApplyConfig, "region"), - HostedZoneId: maputil.GetString(options.ProviderApplyConfig, "hostedZoneId"), + Region: maputil.GetString(options.ProviderExtendedConfig, "region"), + HostedZoneId: maputil.GetString(options.ProviderExtendedConfig, "hostedZoneId"), DnsPropagationTimeout: options.DnsPropagationTimeout, DnsTTL: options.DnsTTL, }) @@ -279,7 +279,7 @@ func createApplicant(options *applicantOptions) (challenge.Provider, error) { applicant, err := pHuaweiCloud.NewChallengeProvider(&pHuaweiCloud.ChallengeProviderConfig{ AccessKeyId: access.AccessKeyId, SecretAccessKey: access.SecretAccessKey, - Region: maputil.GetString(options.ProviderApplyConfig, "region"), + Region: maputil.GetString(options.ProviderExtendedConfig, "region"), DnsPropagationTimeout: options.DnsPropagationTimeout, DnsTTL: options.DnsTTL, }) @@ -296,7 +296,7 @@ func createApplicant(options *applicantOptions) (challenge.Provider, error) { applicant, err := pJDCloud.NewChallengeProvider(&pJDCloud.ChallengeProviderConfig{ AccessKeyId: access.AccessKeyId, AccessKeySecret: access.AccessKeySecret, - RegionId: maputil.GetString(options.ProviderApplyConfig, "regionId"), + RegionId: maputil.GetString(options.ProviderExtendedConfig, "regionId"), DnsPropagationTimeout: options.DnsPropagationTimeout, DnsTTL: options.DnsTTL, }) @@ -433,7 +433,7 @@ func createApplicant(options *applicantOptions) (challenge.Provider, error) { applicant, err := pTencentCloudEO.NewChallengeProvider(&pTencentCloudEO.ChallengeProviderConfig{ SecretId: access.SecretId, SecretKey: access.SecretKey, - ZoneId: maputil.GetString(options.ProviderApplyConfig, "zoneId"), + ZoneId: maputil.GetString(options.ProviderExtendedConfig, "zoneId"), DnsPropagationTimeout: options.DnsPropagationTimeout, DnsTTL: options.DnsTTL, }) diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index 6efc001c..c023a236 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -32,18 +32,23 @@ func NewWithDeployNode(node *domain.WorkflowNode, certdata struct { } nodeConfig := node.GetConfigForDeploy() - - accessRepo := repository.NewAccessRepository() - access, err := accessRepo.GetById(context.Background(), nodeConfig.ProviderAccessId) - if err != nil { - return nil, fmt.Errorf("failed to get access #%s record: %w", nodeConfig.ProviderAccessId, err) + options := &deployerOptions{ + Provider: domain.DeployProviderType(nodeConfig.Provider), + ProviderAccessConfig: make(map[string]any), + ProviderDeployConfig: nodeConfig.ProviderConfig, } - deployer, err := createDeployer(&deployerOptions{ - Provider: domain.DeployProviderType(nodeConfig.Provider), - ProviderAccessConfig: access.Config, - ProviderDeployConfig: nodeConfig.ProviderConfig, - }) + accessRepo := repository.NewAccessRepository() + if nodeConfig.ProviderAccessId != "" { + access, err := accessRepo.GetById(context.Background(), nodeConfig.ProviderAccessId) + if err != nil { + return nil, fmt.Errorf("failed to get access #%s record: %w", nodeConfig.ProviderAccessId, err) + } else { + options.ProviderAccessConfig = access.Config + } + } + + deployer, err := createDeployer(options) if err != nil { return nil, err } diff --git a/internal/domain/access.go b/internal/domain/access.go index a534db69..0d3528ab 100644 --- a/internal/domain/access.go +++ b/internal/domain/access.go @@ -126,6 +126,11 @@ type AccessConfigForGoDaddy struct { ApiSecret string `json:"apiSecret"` } +type AccessConfigForGoogleTrustServices struct { + EabKid string `json:"eabKid"` + EabHmacKey string `json:"eabHmacKey"` +} + type AccessConfigForHuaweiCloud struct { AccessKeyId string `json:"accessKeyId"` SecretAccessKey string `json:"secretAccessKey"` @@ -140,8 +145,6 @@ type AccessConfigForKubernetes struct { KubeConfig string `json:"kubeConfig,omitempty"` } -type AccessConfigForLocal struct{} - type AccessConfigForNamecheap struct { Username string `json:"username"` ApiKey string `json:"apiKey"` @@ -194,6 +197,11 @@ type AccessConfigForSSH struct { KeyPassphrase string `json:"keyPassphrase,omitempty"` } +type AccessConfigForSSLCom struct { + EabKid string `json:"eabKid"` + EabHmacKey string `json:"eabHmacKey"` +} + type AccessConfigForTencentCloud struct { SecretId string `json:"secretId"` SecretKey string `json:"secretKey"` @@ -229,3 +237,8 @@ type AccessConfigForWestcn struct { Username string `json:"username"` ApiPassword string `json:"password"` } + +type AccessConfigForZeroSSL struct { + EabKid string `json:"eabKid"` + EabHmacKey string `json:"eabHmacKey"` +} diff --git a/internal/domain/provider.go b/internal/domain/provider.go index 0e4bcce3..d8726034 100644 --- a/internal/domain/provider.go +++ b/internal/domain/provider.go @@ -9,55 +9,79 @@ type AccessProviderType string NOTICE: If you add new constant, please keep ASCII order. */ const ( - AccessProviderType1Panel = AccessProviderType("1panel") - AccessProviderTypeACMEHttpReq = AccessProviderType("acmehttpreq") - AccessProviderTypeAkamai = AccessProviderType("akamai") // Akamai(预留) - AccessProviderTypeAliyun = AccessProviderType("aliyun") - AccessProviderTypeAWS = AccessProviderType("aws") - AccessProviderTypeAzure = AccessProviderType("azure") - AccessProviderTypeBaiduCloud = AccessProviderType("baiducloud") - AccessProviderTypeBaishan = AccessProviderType("baishan") - AccessProviderTypeBaotaPanel = AccessProviderType("baotapanel") - AccessProviderTypeBytePlus = AccessProviderType("byteplus") - AccessProviderTypeCacheFly = AccessProviderType("cachefly") - AccessProviderTypeCdnfly = AccessProviderType("cdnfly") - AccessProviderTypeCloudflare = AccessProviderType("cloudflare") - AccessProviderTypeClouDNS = AccessProviderType("cloudns") - AccessProviderTypeCMCCCloud = AccessProviderType("cmcccloud") - AccessProviderTypeCTCCCloud = AccessProviderType("ctcccloud") // 联通云(预留) - AccessProviderTypeCUCCCloud = AccessProviderType("cucccloud") // 天翼云(预留) - AccessProviderTypeDeSEC = AccessProviderType("desec") - AccessProviderTypeDNSLA = AccessProviderType("dnsla") - AccessProviderTypeDogeCloud = AccessProviderType("dogecloud") - AccessProviderTypeDynv6 = AccessProviderType("dynv6") - AccessProviderTypeEdgio = AccessProviderType("edgio") - AccessProviderTypeFastly = AccessProviderType("fastly") // Fastly(预留) - AccessProviderTypeGname = AccessProviderType("gname") - AccessProviderTypeGcore = AccessProviderType("gcore") - AccessProviderTypeGoDaddy = AccessProviderType("godaddy") - AccessProviderTypeGoEdge = AccessProviderType("goedge") // GoEdge(预留) - AccessProviderTypeHuaweiCloud = AccessProviderType("huaweicloud") - AccessProviderTypeJDCloud = AccessProviderType("jdcloud") - AccessProviderTypeKubernetes = AccessProviderType("k8s") - AccessProviderTypeLocal = AccessProviderType("local") - AccessProviderTypeNamecheap = AccessProviderType("namecheap") - AccessProviderTypeNameDotCom = AccessProviderType("namedotcom") - AccessProviderTypeNameSilo = AccessProviderType("namesilo") - AccessProviderTypeNS1 = AccessProviderType("ns1") - AccessProviderTypePorkbun = AccessProviderType("porkbun") - AccessProviderTypePowerDNS = AccessProviderType("powerdns") - AccessProviderTypeQiniu = AccessProviderType("qiniu") - AccessProviderTypeQingCloud = AccessProviderType("qingcloud") // 青云(预留) - AccessProviderTypeRainYun = AccessProviderType("rainyun") - AccessProviderTypeSafeLine = AccessProviderType("safeline") - AccessProviderTypeSSH = AccessProviderType("ssh") - AccessProviderTypeTencentCloud = AccessProviderType("tencentcloud") - AccessProviderTypeUCloud = AccessProviderType("ucloud") - AccessProviderTypeUpyun = AccessProviderType("upyun") - AccessProviderTypeVercel = AccessProviderType("vercel") - AccessProviderTypeVolcEngine = AccessProviderType("volcengine") - AccessProviderTypeWebhook = AccessProviderType("webhook") - AccessProviderTypeWestcn = AccessProviderType("westcn") + AccessProviderType1Panel = AccessProviderType("1panel") + AccessProviderTypeACMEHttpReq = AccessProviderType("acmehttpreq") + AccessProviderTypeAkamai = AccessProviderType("akamai") // Akamai(预留) + AccessProviderTypeAliyun = AccessProviderType("aliyun") + AccessProviderTypeAWS = AccessProviderType("aws") + AccessProviderTypeAzure = AccessProviderType("azure") + AccessProviderTypeBaiduCloud = AccessProviderType("baiducloud") + AccessProviderTypeBaishan = AccessProviderType("baishan") + AccessProviderTypeBaotaPanel = AccessProviderType("baotapanel") + AccessProviderTypeBytePlus = AccessProviderType("byteplus") + AccessProviderTypeBuypass = AccessProviderType("buypass") + AccessProviderTypeCacheFly = AccessProviderType("cachefly") + AccessProviderTypeCdnfly = AccessProviderType("cdnfly") + AccessProviderTypeCloudflare = AccessProviderType("cloudflare") + AccessProviderTypeClouDNS = AccessProviderType("cloudns") + AccessProviderTypeCMCCCloud = AccessProviderType("cmcccloud") + AccessProviderTypeCTCCCloud = AccessProviderType("ctcccloud") // 联通云(预留) + AccessProviderTypeCUCCCloud = AccessProviderType("cucccloud") // 天翼云(预留) + AccessProviderTypeDeSEC = AccessProviderType("desec") + AccessProviderTypeDNSLA = AccessProviderType("dnsla") + AccessProviderTypeDogeCloud = AccessProviderType("dogecloud") + AccessProviderTypeDynv6 = AccessProviderType("dynv6") + AccessProviderTypeEdgio = AccessProviderType("edgio") + AccessProviderTypeFastly = AccessProviderType("fastly") // Fastly(预留) + AccessProviderTypeGname = AccessProviderType("gname") + AccessProviderTypeGcore = AccessProviderType("gcore") + AccessProviderTypeGoDaddy = AccessProviderType("godaddy") + AccessProviderTypeGoEdge = AccessProviderType("goedge") // GoEdge(预留) + AccessProviderTypeGoogleTrustServices = AccessProviderType("googletrustservices") + AccessProviderTypeHuaweiCloud = AccessProviderType("huaweicloud") + AccessProviderTypeJDCloud = AccessProviderType("jdcloud") + AccessProviderTypeKubernetes = AccessProviderType("k8s") + AccessProviderTypeLetsEncrypt = AccessProviderType("letsencrypt") + AccessProviderTypeLetsEncryptStaging = AccessProviderType("letsencryptstaging") + AccessProviderTypeLocal = AccessProviderType("local") + AccessProviderTypeNamecheap = AccessProviderType("namecheap") + AccessProviderTypeNameDotCom = AccessProviderType("namedotcom") + AccessProviderTypeNameSilo = AccessProviderType("namesilo") + AccessProviderTypeNS1 = AccessProviderType("ns1") + AccessProviderTypePorkbun = AccessProviderType("porkbun") + AccessProviderTypePowerDNS = AccessProviderType("powerdns") + AccessProviderTypeQiniu = AccessProviderType("qiniu") + AccessProviderTypeQingCloud = AccessProviderType("qingcloud") // 青云(预留) + AccessProviderTypeRainYun = AccessProviderType("rainyun") + AccessProviderTypeSafeLine = AccessProviderType("safeline") + AccessProviderTypeSSH = AccessProviderType("ssh") + AccessProviderTypeSSLCOM = AccessProviderType("sslcom") + AccessProviderTypeTencentCloud = AccessProviderType("tencentcloud") + AccessProviderTypeUCloud = AccessProviderType("ucloud") + AccessProviderTypeUpyun = AccessProviderType("upyun") + AccessProviderTypeVercel = AccessProviderType("vercel") + AccessProviderTypeVolcEngine = AccessProviderType("volcengine") + AccessProviderTypeWebhook = AccessProviderType("webhook") + AccessProviderTypeWestcn = AccessProviderType("westcn") + AccessProviderTypeZeroSSL = AccessProviderType("zerossl") +) + +type ApplyCAProviderType string + +/* +申请证书 CA 提供商常量值。 +始终等于授权提供商类型。 + + 注意:如果追加新的常量值,请保持以 ASCII 排序。 + NOTICE: If you add new constant, please keep ASCII order. +*/ +const ( + ApplyCAProviderTypeBuypass = ApplyCAProviderType(string(AccessProviderTypeBuypass)) + ApplyCAProviderTypeGoogleTrustServices = ApplyCAProviderType(string(AccessProviderTypeGoogleTrustServices)) + ApplyCAProviderTypeLetsEncrypt = ApplyCAProviderType(string(AccessProviderTypeLetsEncrypt)) + ApplyCAProviderTypeLetsEncryptStaging = ApplyCAProviderType(string(AccessProviderTypeLetsEncryptStaging)) + ApplyCAProviderTypeSSLCom = ApplyCAProviderType(string(AccessProviderTypeSSLCOM)) + ApplyCAProviderTypeZeroSSL = ApplyCAProviderType(string(AccessProviderTypeZeroSSL)) ) type ApplyDNSProviderType string @@ -111,7 +135,7 @@ const ( type DeployProviderType string /* -部署目标提供商常量值。 +部署证书主机提供商常量值。 短横线前的部分始终等于授权提供商类型。 注意:如果追加新的常量值,请保持以 ASCII 排序。 diff --git a/internal/domain/workflow.go b/internal/domain/workflow.go index 50069865..841a041d 100644 --- a/internal/domain/workflow.go +++ b/internal/domain/workflow.go @@ -62,19 +62,22 @@ type WorkflowNode struct { } type WorkflowNodeConfigForApply struct { - Domains string `json:"domains"` // 域名列表,以半角分号分隔 - ContactEmail string `json:"contactEmail"` // 联系邮箱 - ChallengeType string `json:"challengeType"` // TODO: 验证方式。目前仅支持 dns-01 - Provider string `json:"provider"` // DNS 提供商 - ProviderAccessId string `json:"providerAccessId"` // DNS 提供商授权记录 ID - ProviderConfig map[string]any `json:"providerConfig"` // DNS 提供商额外配置 - KeyAlgorithm string `json:"keyAlgorithm"` // 密钥算法 - Nameservers string `json:"nameservers"` // DNS 服务器列表,以半角分号分隔 - DnsPropagationTimeout int32 `json:"dnsPropagationTimeout"` // DNS 传播超时时间(零值取决于提供商的默认值) - DnsTTL int32 `json:"dnsTTL"` // DNS TTL(零值取决于提供商的默认值) - DisableFollowCNAME bool `json:"disableFollowCNAME"` // 是否关闭 CNAME 跟随 - DisableARI bool `json:"disableARI"` // 是否关闭 ARI - SkipBeforeExpiryDays int32 `json:"skipBeforeExpiryDays"` // 证书到期前多少天前跳过续期(零值将使用默认值 30) + Domains string `json:"domains"` // 域名列表,以半角分号分隔 + ContactEmail string `json:"contactEmail"` // 联系邮箱 + ChallengeType string `json:"challengeType"` // TODO: 验证方式。目前仅支持 dns-01 + Provider string `json:"provider"` // DNS 提供商 + ProviderAccessId string `json:"providerAccessId"` // DNS 提供商授权记录 ID + ProviderConfig map[string]any `json:"providerConfig"` // DNS 提供商额外配置 + CAProvider string `json:"caProvider,omitempty"` // CA 提供商(零值将使用全局配置) + CAProviderAccessId string `json:"caProviderAccessId,omitempty"` // CA 提供商授权记录 ID + CAProviderConfig map[string]any `json:"caProviderConfig,omitempty"` // CA 提供商额外配置 + KeyAlgorithm string `json:"keyAlgorithm"` // 密钥算法 + Nameservers string `json:"nameservers,omitempty"` // DNS 服务器列表,以半角分号分隔 + DnsPropagationTimeout int32 `json:"dnsPropagationTimeout,omitempty"` // DNS 传播超时时间(零值取决于提供商的默认值) + DnsTTL int32 `json:"dnsTTL,omitempty"` // DNS TTL(零值取决于提供商的默认值) + DisableFollowCNAME bool `json:"disableFollowCNAME,omitempty"` // 是否关闭 CNAME 跟随 + DisableARI bool `json:"disableARI,omitempty"` // 是否关闭 ARI + SkipBeforeExpiryDays int32 `json:"skipBeforeExpiryDays,omitempty"` // 证书到期前多少天前跳过续期(零值将使用默认值 30) } type WorkflowNodeConfigForUpload struct { @@ -84,11 +87,11 @@ type WorkflowNodeConfigForUpload struct { } type WorkflowNodeConfigForDeploy struct { - Certificate string `json:"certificate"` // 前序节点输出的证书,形如“${NodeId}#certificate” - Provider string `json:"provider"` // 主机提供商 - ProviderAccessId string `json:"providerAccessId"` // 主机提供商授权记录 ID - ProviderConfig map[string]any `json:"providerConfig"` // 主机提供商额外配置 - SkipOnLastSucceeded bool `json:"skipOnLastSucceeded"` // 上次部署成功时是否跳过 + Certificate string `json:"certificate"` // 前序节点输出的证书,形如“${NodeId}#certificate” + Provider string `json:"provider"` // 主机提供商 + ProviderAccessId string `json:"providerAccessId,omitempty"` // 主机提供商授权记录 ID + ProviderConfig map[string]any `json:"providerConfig,omitempty"` // 主机提供商额外配置 + SkipOnLastSucceeded bool `json:"skipOnLastSucceeded"` // 上次部署成功时是否跳过 } type WorkflowNodeConfigForNotify struct { @@ -97,73 +100,54 @@ type WorkflowNodeConfigForNotify struct { Message string `json:"message"` // 通知内容 } -func (n *WorkflowNode) getConfigString(key string) string { - return maputil.GetString(n.Config, key) -} - -func (n *WorkflowNode) getConfigBool(key string) bool { - return maputil.GetBool(n.Config, key) -} - -func (n *WorkflowNode) getConfigInt32(key string) int32 { - return maputil.GetInt32(n.Config, key) -} - -func (n *WorkflowNode) getConfigMap(key string) map[string]any { - if val, ok := n.Config[key]; ok { - if result, ok := val.(map[string]any); ok { - return result - } - } - - return make(map[string]any) -} - func (n *WorkflowNode) GetConfigForApply() WorkflowNodeConfigForApply { - skipBeforeExpiryDays := n.getConfigInt32("skipBeforeExpiryDays") + skipBeforeExpiryDays := maputil.GetInt32(n.Config, "skipBeforeExpiryDays") if skipBeforeExpiryDays == 0 { skipBeforeExpiryDays = 30 } return WorkflowNodeConfigForApply{ - Domains: n.getConfigString("domains"), - ContactEmail: n.getConfigString("contactEmail"), - Provider: n.getConfigString("provider"), - ProviderAccessId: n.getConfigString("providerAccessId"), - ProviderConfig: n.getConfigMap("providerConfig"), - KeyAlgorithm: n.getConfigString("keyAlgorithm"), - Nameservers: n.getConfigString("nameservers"), - DnsPropagationTimeout: n.getConfigInt32("dnsPropagationTimeout"), - DnsTTL: n.getConfigInt32("dnsTTL"), - DisableFollowCNAME: n.getConfigBool("disableFollowCNAME"), - DisableARI: n.getConfigBool("disableARI"), + Domains: maputil.GetString(n.Config, "domains"), + ContactEmail: maputil.GetString(n.Config, "contactEmail"), + Provider: maputil.GetString(n.Config, "provider"), + ProviderAccessId: maputil.GetString(n.Config, "providerAccessId"), + ProviderConfig: maputil.GetAnyMap(n.Config, "providerConfig"), + CAProvider: maputil.GetString(n.Config, "caProvider"), + CAProviderAccessId: maputil.GetString(n.Config, "caProviderAccessId"), + CAProviderConfig: maputil.GetAnyMap(n.Config, "caProviderConfig"), + KeyAlgorithm: maputil.GetString(n.Config, "keyAlgorithm"), + Nameservers: maputil.GetString(n.Config, "nameservers"), + DnsPropagationTimeout: maputil.GetInt32(n.Config, "dnsPropagationTimeout"), + DnsTTL: maputil.GetInt32(n.Config, "dnsTTL"), + DisableFollowCNAME: maputil.GetBool(n.Config, "disableFollowCNAME"), + DisableARI: maputil.GetBool(n.Config, "disableARI"), SkipBeforeExpiryDays: skipBeforeExpiryDays, } } func (n *WorkflowNode) GetConfigForUpload() WorkflowNodeConfigForUpload { return WorkflowNodeConfigForUpload{ - Certificate: n.getConfigString("certificate"), - PrivateKey: n.getConfigString("privateKey"), - Domains: n.getConfigString("domains"), + Certificate: maputil.GetString(n.Config, "certificate"), + PrivateKey: maputil.GetString(n.Config, "privateKey"), + Domains: maputil.GetString(n.Config, "domains"), } } func (n *WorkflowNode) GetConfigForDeploy() WorkflowNodeConfigForDeploy { return WorkflowNodeConfigForDeploy{ - Certificate: n.getConfigString("certificate"), - Provider: n.getConfigString("provider"), - ProviderAccessId: n.getConfigString("providerAccessId"), - ProviderConfig: n.getConfigMap("providerConfig"), - SkipOnLastSucceeded: n.getConfigBool("skipOnLastSucceeded"), + Certificate: maputil.GetString(n.Config, "certificate"), + Provider: maputil.GetString(n.Config, "provider"), + ProviderAccessId: maputil.GetString(n.Config, "providerAccessId"), + ProviderConfig: maputil.GetAnyMap(n.Config, "providerConfig"), + SkipOnLastSucceeded: maputil.GetBool(n.Config, "skipOnLastSucceeded"), } } func (n *WorkflowNode) GetConfigForNotify() WorkflowNodeConfigForNotify { return WorkflowNodeConfigForNotify{ - Channel: n.getConfigString("channel"), - Subject: n.getConfigString("subject"), - Message: n.getConfigString("message"), + Channel: maputil.GetString(n.Config, "channel"), + Subject: maputil.GetString(n.Config, "subject"), + Message: maputil.GetString(n.Config, "message"), } } diff --git a/internal/pkg/utils/maputil/getter.go b/internal/pkg/utils/maputil/getter.go index 36561381..9ba22875 100644 --- a/internal/pkg/utils/maputil/getter.go +++ b/internal/pkg/utils/maputil/getter.go @@ -180,3 +180,25 @@ func GetOrDefaultBool(dict map[string]any, key string, defaultValue bool) bool { return defaultValue } + +// 以 `map[string]any` 形式从字典中获取指定键的值。 +// +// 入参: +// - dict: 字典。 +// - key: 键。 +// +// 出参: +// - 字典中键对应的 `map[string]any` 对象。 +func GetAnyMap(dict map[string]any, key string) map[string]any { + if dict == nil { + return make(map[string]any) + } + + if val, ok := dict[key]; ok { + if result, ok := val.(map[string]any); ok { + return result + } + } + + return make(map[string]any) +} diff --git a/internal/workflow/node-processor/apply_node.go b/internal/workflow/node-processor/apply_node.go index a1ca977d..b9769f3d 100644 --- a/internal/workflow/node-processor/apply_node.go +++ b/internal/workflow/node-processor/apply_node.go @@ -109,12 +109,24 @@ func (n *applyNode) checkCanSkip(ctx context.Context, lastOutput *domain.Workflo if currentNodeConfig.ContactEmail != lastNodeConfig.ContactEmail { return false, "the configuration item 'ContactEmail' changed" } + if currentNodeConfig.Provider != lastNodeConfig.Provider { + return false, "the configuration item 'Provider' changed" + } if currentNodeConfig.ProviderAccessId != lastNodeConfig.ProviderAccessId { return false, "the configuration item 'ProviderAccessId' changed" } if !maps.Equal(currentNodeConfig.ProviderConfig, lastNodeConfig.ProviderConfig) { return false, "the configuration item 'ProviderConfig' changed" } + if currentNodeConfig.CAProvider != lastNodeConfig.CAProvider { + return false, "the configuration item 'CAProvider' changed" + } + if currentNodeConfig.CAProviderAccessId != lastNodeConfig.CAProviderAccessId { + return false, "the configuration item 'CAProviderAccessId' changed" + } + if !maps.Equal(currentNodeConfig.CAProviderConfig, lastNodeConfig.CAProviderConfig) { + return false, "the configuration item 'CAProviderConfig' changed" + } if currentNodeConfig.KeyAlgorithm != lastNodeConfig.KeyAlgorithm { return false, "the configuration item 'KeyAlgorithm' changed" } diff --git a/migrations/1742209200_upgrade.go b/migrations/1742209200_upgrade.go index 0a980972..d2ed7f9d 100644 --- a/migrations/1742209200_upgrade.go +++ b/migrations/1742209200_upgrade.go @@ -7,8 +7,6 @@ import ( "github.com/pocketbase/pocketbase/core" m "github.com/pocketbase/pocketbase/migrations" - - "github.com/usual2970/certimate/internal/domain" ) func init() { @@ -179,20 +177,20 @@ func init() { } for _, workflowRun := range workflowRuns { - type oldWorkflowRunLogRecord struct { + type dWorkflowRunLogRecord struct { Time string `json:"time"` Level string `json:"level"` Content string `json:"content"` Error string `json:"error"` } - type oldWorkflowRunLog struct { - NodeId string `json:"nodeId"` - NodeName string `json:"nodeName"` - Records []oldWorkflowRunLogRecord `json:"records"` - Error string `json:"error"` + type dWorkflowRunLog struct { + NodeId string `json:"nodeId"` + NodeName string `json:"nodeName"` + Records []dWorkflowRunLogRecord `json:"records"` + Error string `json:"error"` } - logs := make([]oldWorkflowRunLog, 0) + logs := make([]dWorkflowRunLog, 0) if err := workflowRun.UnmarshalJSONField("logs", &logs); err != nil { continue } @@ -259,8 +257,20 @@ func init() { return err } + type dWorkflowNode struct { + Id string `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + Config map[string]any `json:"config"` + Inputs map[string]any `json:"inputs"` + Outputs map[string]any `json:"outputs"` + Next *dWorkflowNode `json:"next,omitempty"` + Branches []dWorkflowNode `json:"branches,omitempty"` + Validated bool `json:"validated"` + } + for _, workflowRun := range workflowRuns { - node := &domain.WorkflowNode{} + node := &dWorkflowNode{} for _, workflowOutput := range workflowOutputs { if workflowOutput.GetString("runId") != workflowRun.Get("id") { continue @@ -270,8 +280,8 @@ func init() { continue } - if node.Type != domain.WorkflowNodeTypeApply { - node = &domain.WorkflowNode{} + if node.Type != "apply" { + node = &dWorkflowNode{} continue } } @@ -286,7 +296,7 @@ func init() { } else { workflow, _ := app.FindRecordById("workflow", workflowRun.GetString("workflowId")) if workflow != nil { - rootNode := &domain.WorkflowNode{} + rootNode := &dWorkflowNode{} if err := workflow.UnmarshalJSONField("content", rootNode); err != nil { return err } @@ -294,9 +304,9 @@ func init() { rootNode.Next = node workflowRun.Set("detail", rootNode) } else { - rootNode := &domain.WorkflowNode{ + rootNode := &dWorkflowNode{ Id: core.GenerateDefaultRandomId(), - Type: domain.WorkflowNodeTypeStart, + Type: "start", Name: "开始", Config: map[string]any{ "trigger": "manual", diff --git a/migrations/1743264000_upgrade.go b/migrations/1743264000_upgrade.go new file mode 100644 index 00000000..75f98b30 --- /dev/null +++ b/migrations/1743264000_upgrade.go @@ -0,0 +1,173 @@ +package migrations + +import ( + "github.com/pocketbase/pocketbase/core" + m "github.com/pocketbase/pocketbase/migrations" +) + +func init() { + m.Register(func(app core.App) error { + // update collection `settings` + { + collection, err := app.FindCollectionByNameOrId("dy6ccjb60spfy6p") + if err != nil { + return err + } + + records, err := app.FindRecordsByFilter(collection, "name='sslProvider'", "-created", 1, 0) + if err != nil { + return err + } + + if len(records) == 1 { + record := records[0] + + content := make(map[string]any) + if err := record.UnmarshalJSONField("content", &content); err != nil { + return err + } + + if provider, ok := content["provider"]; ok { + if providerStr, ok := provider.(string); ok { + if providerStr == "letsencrypt_staging" { + content["provider"] = "letsencryptstaging" + } + } + } + + if config, ok := content["config"]; ok { + if configMap, ok := config.(map[string]any); ok { + if _, ok := configMap["letsencrypt_staging"]; ok { + configMap["letsencryptstaging"] = configMap["letsencrypt_staging"] + delete(configMap, "letsencrypt_staging") + } + if _, ok := configMap["gts"]; ok { + configMap["googletrustservices"] = configMap["gts"] + delete(configMap, "gts") + } + } + } + + record.Set("content", content) + if err := app.Save(record); err != nil { + return err + } + } + } + + // update collection `access` + { + 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", + "webhook", + "westcn", + "zerossl" + ] + }`)); err != nil { + return err + } + + if err := app.Save(collection); err != nil { + return err + } + } + + // update collection `acme_accounts` + { + collection, err := app.FindCollectionByNameOrId("012d7abbod1hwvr") + if err != nil { + return err + } + + records, err := app.FindRecordsByFilter(collection, "ca='letsencrypt_staging' || ca='gts'", "-created", 0, 0) + if err != nil { + return err + } + + for _, record := range records { + ca := record.GetString("ca") + if ca == "letsencrypt_staging" { + record.Set("ca", "letsencryptstaging") + } else if ca == "gts" { + record.Set("ca", "googletrustservices") + } else { + continue + } + + if err := app.Save(record); err != nil { + return err + } + } + } + + return nil + }, func(app core.App) error { + return nil + }) +} diff --git a/ui/public/imgs/acme/google.svg b/ui/public/imgs/acme/google.svg deleted file mode 100644 index 78f81c93..00000000 --- a/ui/public/imgs/acme/google.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/ui/public/imgs/providers/buypass.png b/ui/public/imgs/providers/buypass.png new file mode 100644 index 00000000..f4692538 Binary files /dev/null and b/ui/public/imgs/providers/buypass.png differ diff --git a/ui/public/imgs/providers/google.svg b/ui/public/imgs/providers/google.svg new file mode 100644 index 00000000..350cdfcc --- /dev/null +++ b/ui/public/imgs/providers/google.svg @@ -0,0 +1 @@ + diff --git a/ui/public/imgs/acme/letsencrypt.svg b/ui/public/imgs/providers/letsencrypt.svg similarity index 100% rename from ui/public/imgs/acme/letsencrypt.svg rename to ui/public/imgs/providers/letsencrypt.svg diff --git a/ui/public/imgs/providers/sslcom.svg b/ui/public/imgs/providers/sslcom.svg new file mode 100644 index 00000000..4cd53176 --- /dev/null +++ b/ui/public/imgs/providers/sslcom.svg @@ -0,0 +1 @@ + diff --git a/ui/public/imgs/acme/zerossl.svg b/ui/public/imgs/providers/zerossl.svg similarity index 100% rename from ui/public/imgs/acme/zerossl.svg rename to ui/public/imgs/providers/zerossl.svg diff --git a/ui/src/components/access/AccessEditDrawer.tsx b/ui/src/components/access/AccessEditDrawer.tsx new file mode 100644 index 00000000..48002af7 --- /dev/null +++ b/ui/src/components/access/AccessEditDrawer.tsx @@ -0,0 +1,118 @@ +import { useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useControllableValue } from "ahooks"; +import { Button, Drawer, Space, notification } from "antd"; + +import { type AccessModel } from "@/domain/access"; +import { useTriggerElement, useZustandShallowSelector } from "@/hooks"; +import { useAccessesStore } from "@/stores/access"; +import { getErrMsg } from "@/utils/error"; + +import AccessForm, { type AccessFormInstance, type AccessFormProps } from "./AccessForm"; + +export type AccessEditDrawerProps = { + data?: AccessFormProps["initialValues"]; + loading?: boolean; + open?: boolean; + range?: AccessFormProps["range"]; + scene: AccessFormProps["scene"]; + trigger?: React.ReactNode; + onOpenChange?: (open: boolean) => void; + afterSubmit?: (record: AccessModel) => void; +}; + +const AccessEditDrawer = ({ data, loading, trigger, scene, range, afterSubmit, ...props }: AccessEditDrawerProps) => { + const { t } = useTranslation(); + + const [notificationApi, NotificationContextHolder] = notification.useNotification(); + + const { createAccess, updateAccess } = useAccessesStore(useZustandShallowSelector(["createAccess", "updateAccess"])); + + const [open, setOpen] = useControllableValue(props, { + valuePropName: "open", + defaultValuePropName: "defaultOpen", + trigger: "onOpenChange", + }); + + const triggerEl = useTriggerElement(trigger, { onClick: () => setOpen(true) }); + + const formRef = useRef(null); + const [formPending, setFormPending] = useState(false); + + const handleOkClick = async () => { + setFormPending(true); + try { + await formRef.current!.validateFields(); + } catch (err) { + setFormPending(false); + throw err; + } + + try { + let values: AccessModel = formRef.current!.getFieldsValue(); + + if (scene === "add") { + if (data?.id) { + throw "Invalid props: `data`"; + } + + values = await createAccess(values); + } else if (scene === "edit") { + if (!data?.id) { + throw "Invalid props: `data`"; + } + + values = await updateAccess({ ...data, ...values }); + } else { + throw "Invalid props: `scene`"; + } + + afterSubmit?.(values); + setOpen(false); + } catch (err) { + notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) }); + + throw err; + } finally { + setFormPending(false); + } + }; + + const handleCancelClick = () => { + if (formPending) return; + + setOpen(false); + }; + + return ( + <> + {NotificationContextHolder} + + {triggerEl} + + + + + + } + loading={loading} + maskClosable={!formPending} + open={open} + title={t(`access.action.${scene}`)} + width={720} + onClose={() => setOpen(false)} + > + + + + ); +}; + +export default AccessEditDrawer; diff --git a/ui/src/components/access/AccessEditModal.tsx b/ui/src/components/access/AccessEditModal.tsx index 66154866..0d181877 100644 --- a/ui/src/components/access/AccessEditModal.tsx +++ b/ui/src/components/access/AccessEditModal.tsx @@ -14,13 +14,14 @@ export type AccessEditModalProps = { data?: AccessFormProps["initialValues"]; loading?: boolean; open?: boolean; - preset: AccessFormProps["preset"]; + range?: AccessFormProps["range"]; + scene: AccessFormProps["scene"]; trigger?: React.ReactNode; onOpenChange?: (open: boolean) => void; afterSubmit?: (record: AccessModel) => void; }; -const AccessEditModal = ({ data, loading, trigger, preset, afterSubmit, ...props }: AccessEditModalProps) => { +const AccessEditModal = ({ data, loading, trigger, scene, range, afterSubmit, ...props }: AccessEditModalProps) => { const { t } = useTranslation(); const [notificationApi, NotificationContextHolder] = notification.useNotification(); @@ -50,13 +51,13 @@ const AccessEditModal = ({ data, loading, trigger, preset, afterSubmit, ...props try { let values: AccessModel = formRef.current!.getFieldsValue(); - if (preset === "add") { + if (scene === "add") { if (data?.id) { throw "Invalid props: `data`"; } values = await createAccess(values); - } else if (preset === "edit") { + } else if (scene === "edit") { if (!data?.id) { throw "Invalid props: `data`"; } @@ -96,15 +97,15 @@ const AccessEditModal = ({ data, loading, trigger, preset, afterSubmit, ...props confirmLoading={formPending} destroyOnClose loading={loading} - okText={preset === "edit" ? t("common.button.save") : t("common.button.submit")} + okText={scene === "edit" ? t("common.button.save") : t("common.button.submit")} open={open} - title={t(`access.action.${preset}`)} + title={t(`access.action.${scene}`)} width={480} onOk={handleOkClick} onCancel={handleCancelClick} >
- +
diff --git a/ui/src/components/access/AccessForm.tsx b/ui/src/components/access/AccessForm.tsx index 487d449a..d5906434 100644 --- a/ui/src/components/access/AccessForm.tsx +++ b/ui/src/components/access/AccessForm.tsx @@ -6,7 +6,7 @@ import { z } from "zod"; import AccessProviderSelect from "@/components/provider/AccessProviderSelect"; import { type AccessModel } from "@/domain/access"; -import { ACCESS_PROVIDERS } from "@/domain/provider"; +import { ACCESS_PROVIDERS, ACCESS_USAGES } from "@/domain/provider"; import { useAntdForm, useAntdFormName } from "@/hooks"; import AccessForm1PanelConfig from "./AccessForm1PanelConfig"; @@ -31,10 +31,10 @@ import AccessFormEdgioConfig from "./AccessFormEdgioConfig"; import AccessFormGcoreConfig from "./AccessFormGcoreConfig"; import AccessFormGnameConfig from "./AccessFormGnameConfig"; import AccessFormGoDaddyConfig from "./AccessFormGoDaddyConfig"; +import AccessFormGoogleTrustServicesConfig from "./AccessFormGoogleTrustServicesConfig"; import AccessFormHuaweiCloudConfig from "./AccessFormHuaweiCloudConfig"; import AccessFormJDCloudConfig from "./AccessFormJDCloudConfig"; import AccessFormKubernetesConfig from "./AccessFormKubernetesConfig"; -import AccessFormLocalConfig from "./AccessFormLocalConfig"; import AccessFormNamecheapConfig from "./AccessFormNamecheapConfig"; import AccessFormNameDotComConfig from "./AccessFormNameDotComConfig"; import AccessFormNameSiloConfig from "./AccessFormNameSiloConfig"; @@ -45,6 +45,7 @@ import AccessFormQiniuConfig from "./AccessFormQiniuConfig"; import AccessFormRainYunConfig from "./AccessFormRainYunConfig"; import AccessFormSafeLineConfig from "./AccessFormSafeLineConfig"; import AccessFormSSHConfig from "./AccessFormSSHConfig"; +import AccessFormSSLComConfig from "./AccessFormSSLComConfig"; import AccessFormTencentCloudConfig from "./AccessFormTencentCloudConfig"; import AccessFormUCloudConfig from "./AccessFormUCloudConfig"; import AccessFormUpyunConfig from "./AccessFormUpyunConfig"; @@ -52,16 +53,19 @@ import AccessFormVercelConfig from "./AccessFormVercelConfig"; import AccessFormVolcEngineConfig from "./AccessFormVolcEngineConfig"; import AccessFormWebhookConfig from "./AccessFormWebhookConfig"; import AccessFormWestcnConfig from "./AccessFormWestcnConfig"; +import AccessFormZeroSSLConfig from "./AccessFormZeroSSLConfig"; type AccessFormFieldValues = Partial>; -type AccessFormPresets = "add" | "edit"; +type AccessFormRanges = "both-dns-hosting" | "ca-only" | "notify-only"; +type AccessFormScenes = "add" | "edit"; export type AccessFormProps = { className?: string; style?: React.CSSProperties; disabled?: boolean; initialValues?: AccessFormFieldValues; - preset: AccessFormPresets; + range?: AccessFormRanges; + scene: AccessFormScenes; onValuesChange?: (values: AccessFormFieldValues) => void; }; @@ -71,7 +75,7 @@ export type AccessFormInstance = { validateFields: FormInstance["validateFields"]; }; -const AccessForm = forwardRef(({ className, style, disabled, initialValues, preset, onValuesChange }, ref) => { +const AccessForm = forwardRef(({ className, style, disabled, initialValues, range, scene, onValuesChange }, ref) => { const { t } = useTranslation(); const formSchema = z.object({ @@ -80,7 +84,14 @@ const AccessForm = forwardRef(({ className, .min(1, t("access.form.name.placeholder")) .max(64, t("common.errmsg.string_max", { max: 64 })) .trim(), - provider: z.nativeEnum(ACCESS_PROVIDERS, { message: t("access.form.provider.placeholder") }), + provider: z.nativeEnum(ACCESS_PROVIDERS, { + message: + range === "ca-only" + ? t("access.form.certificate_authority.placeholder") + : range === "notify-only" + ? t("access.form.notification_channel.placeholder") + : t("access.form.provider.placeholder"), + }), config: z.any(), }); const formRule = createSchemaFieldRule(formSchema); @@ -88,6 +99,35 @@ const AccessForm = forwardRef(({ className, initialValues: initialValues, }); + const providerLabel = useMemo(() => { + switch (range) { + case "ca-only": + return t("access.form.certificate_authority.label"); + case "notify-only": + return t("access.form.notification_channel.label"); + } + + return t("access.form.provider.label"); + }, [range]); + const providerPlaceholder = useMemo(() => { + switch (range) { + case "ca-only": + return t("access.form.certificate_authority.placeholder"); + case "notify-only": + return t("access.form.notification_channel.placeholder"); + } + + return t("access.form.provider.placeholder"); + }, [range]); + const providerTooltip = useMemo(() => { + switch (range) { + case "both-dns-hosting": + return ; + } + + return undefined; + }, [range]); + const fieldProvider = Form.useWatch("provider", formInst); const [nestedFormInst] = Form.useForm(); @@ -147,6 +187,8 @@ const AccessForm = forwardRef(({ className, return ; case ACCESS_PROVIDERS.GODADDY: return ; + case ACCESS_PROVIDERS.GOOGLETRUSTSERVICES: + return ; case ACCESS_PROVIDERS.EDGIO: return ; case ACCESS_PROVIDERS.HUAWEICLOUD: @@ -155,8 +197,6 @@ const AccessForm = forwardRef(({ className, return ; case ACCESS_PROVIDERS.KUBERNETES: return ; - case ACCESS_PROVIDERS.LOCAL: - return ; case ACCESS_PROVIDERS.NAMECHEAP: return ; case ACCESS_PROVIDERS.NAMEDOTCOM: @@ -177,6 +217,8 @@ const AccessForm = forwardRef(({ className, return ; case ACCESS_PROVIDERS.SSH: return ; + case ACCESS_PROVIDERS.SSLCOM: + return ; case ACCESS_PROVIDERS.TENCENTCLOUD: return ; case ACCESS_PROVIDERS.UCLOUD: @@ -191,6 +233,8 @@ const AccessForm = forwardRef(({ className, return ; case ACCESS_PROVIDERS.WESTCN: return ; + case ACCESS_PROVIDERS.ZEROSSL: + return ; } }, [disabled, initialValues?.config, fieldProvider, nestedFormInst, nestedFormName]); @@ -235,13 +279,25 @@ const AccessForm = forwardRef(({ className, - } - > - + + { + if (range == null) return true; + + switch (range) { + case "both-dns-hosting": + return record.usages.includes(ACCESS_USAGES.DNS) || record.usages.includes(ACCESS_USAGES.HOSTING); + case "ca-only": + return record.usages.includes(ACCESS_USAGES.CA); + case "notify-only": + return record.usages.includes(ACCESS_USAGES.NOTIFICATION); + } + }} + disabled={scene !== "add"} + placeholder={providerPlaceholder} + showOptionTags={range == null || (range === "both-dns-hosting" ? { [ACCESS_USAGES.DNS]: true, [ACCESS_USAGES.HOSTING]: true } : false)} + showSearch={!disabled} + /> diff --git a/ui/src/components/access/AccessFormGoogleTrustServicesConfig.tsx b/ui/src/components/access/AccessFormGoogleTrustServicesConfig.tsx new file mode 100644 index 00000000..95eb6270 --- /dev/null +++ b/ui/src/components/access/AccessFormGoogleTrustServicesConfig.tsx @@ -0,0 +1,82 @@ +import { useTranslation } from "react-i18next"; +import { Form, type FormInstance, Input } from "antd"; +import { createSchemaFieldRule } from "antd-zod"; +import { z } from "zod"; + +import { type AccessConfigForGoogleTrustServices } from "@/domain/access"; + +type AccessFormGoogleTrustServicesConfigFieldValues = Nullish; + +export type AccessFormGoogleTrustServicesConfigProps = { + form: FormInstance; + formName: string; + disabled?: boolean; + initialValues?: AccessFormGoogleTrustServicesConfigFieldValues; + onValuesChange?: (values: AccessFormGoogleTrustServicesConfigFieldValues) => void; +}; + +const initFormModel = (): AccessFormGoogleTrustServicesConfigFieldValues => { + return { + eabKid: "", + eabHmacKey: "", + }; +}; + +const AccessFormGoogleTrustServicesConfig = ({ + form: formInst, + formName, + disabled, + initialValues, + onValuesChange, +}: AccessFormGoogleTrustServicesConfigProps) => { + const { t } = useTranslation(); + + const formSchema = z.object({ + eabKid: z + .string() + .min(1, t("access.form.googletrustservices_eab_kid.placeholder")) + .max(256, t("common.errmsg.string_max", { max: 256 })) + .trim(), + eabHmacKey: z + .string() + .min(1, t("access.form.googletrustservices_eab_hmac_key.placeholder")) + .max(256, t("common.errmsg.string_max", { max: 256 })) + .trim(), + }); + const formRule = createSchemaFieldRule(formSchema); + + const handleFormChange = (_: unknown, values: z.infer) => { + onValuesChange?.(values); + }; + + return ( +
+ } + > + + + + } + > + + +
+ ); +}; + +export default AccessFormGoogleTrustServicesConfig; diff --git a/ui/src/components/access/AccessFormLocalConfig.tsx b/ui/src/components/access/AccessFormLocalConfig.tsx deleted file mode 100644 index cde72374..00000000 --- a/ui/src/components/access/AccessFormLocalConfig.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { Form, type FormInstance } from "antd"; - -import { type AccessConfigForLocal } from "@/domain/access"; - -type AccessFormLocalConfigFieldValues = Nullish; - -export type AccessFormLocalConfigProps = { - form: FormInstance; - formName: string; - disabled?: boolean; - initialValues?: AccessFormLocalConfigFieldValues; - onValuesChange?: (values: AccessFormLocalConfigFieldValues) => void; -}; - -const initFormModel = (): AccessFormLocalConfigFieldValues => { - return {}; -}; - -const AccessFormLocalConfig = ({ form: formInst, formName, disabled, initialValues, onValuesChange }: AccessFormLocalConfigProps) => { - const handleFormChange = (_: unknown, values: any) => { - onValuesChange?.(values); - }; - - return ( -
- ); -}; - -export default AccessFormLocalConfig; diff --git a/ui/src/components/access/AccessFormSSHConfig.tsx b/ui/src/components/access/AccessFormSSHConfig.tsx index d455a5fa..6d3615cf 100644 --- a/ui/src/components/access/AccessFormSSHConfig.tsx +++ b/ui/src/components/access/AccessFormSSHConfig.tsx @@ -7,7 +7,7 @@ import { z } from "zod"; import { type AccessConfigForSSH } from "@/domain/access"; import { readFileContent } from "@/utils/file"; -import { validDomainName, validIPv4Address, validIPv6Address } from "@/utils/validators"; +import { validDomainName, validIPv4Address, validIPv6Address, validPortNumber } from "@/utils/validators"; type AccessFormSSHConfigFieldValues = Nullish; @@ -34,11 +34,13 @@ const AccessFormSSHConfig = ({ form: formInst, formName, disabled, initialValues host: z .string({ message: t("access.form.ssh_host.placeholder") }) .refine((v) => validDomainName(v) || validIPv4Address(v) || validIPv6Address(v), t("common.errmsg.host_invalid")), - port: z - .number({ message: t("access.form.ssh_port.placeholder") }) - .int() - .gte(1, t("common.errmsg.port_invalid")) - .lte(65535, t("common.errmsg.port_invalid")), + port: z.preprocess( + (v) => Number(v), + z + .number({ message: t("access.form.ssh_port.placeholder") }) + .int(t("access.form.ssh_port.placeholder")) + .refine((v) => validPortNumber(v), t("common.errmsg.port_invalid")) + ), username: z .string() .min(1, "access.form.ssh_username.placeholder") diff --git a/ui/src/components/access/AccessFormSSLComConfig.tsx b/ui/src/components/access/AccessFormSSLComConfig.tsx new file mode 100644 index 00000000..85266266 --- /dev/null +++ b/ui/src/components/access/AccessFormSSLComConfig.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 AccessConfigForSSLCom } from "@/domain/access"; + +type AccessFormSSLComConfigFieldValues = Nullish; + +export type AccessFormSSLComConfigProps = { + form: FormInstance; + formName: string; + disabled?: boolean; + initialValues?: AccessFormSSLComConfigFieldValues; + onValuesChange?: (values: AccessFormSSLComConfigFieldValues) => void; +}; + +const initFormModel = (): AccessFormSSLComConfigFieldValues => { + return { + eabKid: "", + eabHmacKey: "", + }; +}; + +const AccessFormSSLComConfig = ({ form: formInst, formName, disabled, initialValues, onValuesChange }: AccessFormSSLComConfigProps) => { + const { t } = useTranslation(); + + const formSchema = z.object({ + eabKid: z + .string() + .min(1, t("access.form.sslcom_eab_kid.placeholder")) + .max(256, t("common.errmsg.string_max", { max: 256 })) + .trim(), + eabHmacKey: z + .string() + .min(1, t("access.form.sslcom_eab_hmac_key.placeholder")) + .max(256, t("common.errmsg.string_max", { max: 256 })) + .trim(), + }); + const formRule = createSchemaFieldRule(formSchema); + + const handleFormChange = (_: unknown, values: z.infer) => { + onValuesChange?.(values); + }; + + return ( +
+ } + > + + + + } + > + + +
+ ); +}; + +export default AccessFormSSLComConfig; diff --git a/ui/src/components/access/AccessFormZeroSSLConfig.tsx b/ui/src/components/access/AccessFormZeroSSLConfig.tsx new file mode 100644 index 00000000..336777b0 --- /dev/null +++ b/ui/src/components/access/AccessFormZeroSSLConfig.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 AccessConfigForZeroSSL } from "@/domain/access"; + +type AccessFormZeroSSLConfigFieldValues = Nullish; + +export type AccessFormZeroSSLConfigProps = { + form: FormInstance; + formName: string; + disabled?: boolean; + initialValues?: AccessFormZeroSSLConfigFieldValues; + onValuesChange?: (values: AccessFormZeroSSLConfigFieldValues) => void; +}; + +const initFormModel = (): AccessFormZeroSSLConfigFieldValues => { + return { + eabKid: "", + eabHmacKey: "", + }; +}; + +const AccessFormZeroSSLConfig = ({ form: formInst, formName, disabled, initialValues, onValuesChange }: AccessFormZeroSSLConfigProps) => { + const { t } = useTranslation(); + + const formSchema = z.object({ + eabKid: z + .string() + .min(1, t("access.form.zerossl_eab_kid.placeholder")) + .max(256, t("common.errmsg.string_max", { max: 256 })) + .trim(), + eabHmacKey: z + .string() + .min(1, t("access.form.zerossl_eab_hmac_key.placeholder")) + .max(256, t("common.errmsg.string_max", { max: 256 })) + .trim(), + }); + const formRule = createSchemaFieldRule(formSchema); + + const handleFormChange = (_: unknown, values: z.infer) => { + onValuesChange?.(values); + }; + + return ( +
+ } + > + + + + } + > + + +
+ ); +}; + +export default AccessFormZeroSSLConfig; diff --git a/ui/src/components/certificate/CertificateDetailDrawer.tsx b/ui/src/components/certificate/CertificateDetailDrawer.tsx index 2cedeb2b..361beb96 100644 --- a/ui/src/components/certificate/CertificateDetailDrawer.tsx +++ b/ui/src/components/certificate/CertificateDetailDrawer.tsx @@ -29,7 +29,6 @@ const CertificateDetailDrawer = ({ data, loading, trigger, ...props }: Certifica { const { t } = useTranslation(); @@ -11,11 +13,13 @@ const NotifyChannelEditFormEmailFields = () => { .string({ message: t("settings.notification.channel.form.email_smtp_host.placeholder") }) .min(1, t("settings.notification.channel.form.email_smtp_host.placeholder")) .max(256, t("common.errmsg.string_max", { max: 256 })), - smtpPort: z - .number({ message: t("settings.notification.channel.form.email_smtp_port.placeholder") }) - .int() - .gte(1, t("common.errmsg.port_invalid")) - .lte(65535, t("common.errmsg.port_invalid")), + smtpPort: z.preprocess( + (v) => Number(v), + z + .number({ message: t("settings.notification.channel.form.email_smtp_port.placeholder") }) + .int(t("settings.notification.channel.form.email_smtp_port.placeholder")) + .refine((v) => validPortNumber(v), t("common.errmsg.port_invalid")) + ), smtpTLS: z.boolean().nullish(), username: z .string({ message: t("settings.notification.channel.form.email_username.placeholder") }) diff --git a/ui/src/components/provider/AccessProviderSelect.tsx b/ui/src/components/provider/AccessProviderSelect.tsx index 0e0a992c..fbb4099f 100644 --- a/ui/src/components/provider/AccessProviderSelect.tsx +++ b/ui/src/components/provider/AccessProviderSelect.tsx @@ -1,17 +1,19 @@ -import { memo, useEffect, useState } from "react"; +import { memo, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Avatar, Select, type SelectProps, Space, Tag, Typography } from "antd"; -import { ACCESS_USAGES, type AccessProvider, accessProvidersMap } from "@/domain/provider"; +import Show from "@/components/Show"; +import { ACCESS_USAGES, type AccessProvider, type AccessUsageType, accessProvidersMap } from "@/domain/provider"; export type AccessProviderSelectProps = Omit< SelectProps, "filterOption" | "filterSort" | "labelRender" | "options" | "optionFilterProp" | "optionLabelProp" | "optionRender" > & { filter?: (record: AccessProvider) => boolean; + showOptionTags?: boolean | { [key in AccessUsageType]?: boolean }; }; -const AccessProviderSelect = ({ filter, ...props }: AccessProviderSelectProps) => { +const AccessProviderSelect = ({ filter, showOptionTags, ...props }: AccessProviderSelectProps = { showOptionTags: true }) => { const { t } = useTranslation(); const [options, setOptions] = useState>([]); @@ -23,33 +25,51 @@ const AccessProviderSelect = ({ filter, ...props }: AccessProviderSelectProps) = key: item.type, value: item.type, label: t(item.name), + disabled: item.builtin, data: item, })) ); }, [filter]); + const showOptionTagForDNS = useMemo(() => { + return typeof showOptionTags === "object" ? !!showOptionTags[ACCESS_USAGES.DNS] : !!showOptionTags; + }, [showOptionTags]); + const showOptionTagForHosting = useMemo(() => { + return typeof showOptionTags === "object" ? !!showOptionTags[ACCESS_USAGES.HOSTING] : !!showOptionTags; + }, [showOptionTags]); + const showOptionTagForCA = useMemo(() => { + return typeof showOptionTags === "object" ? !!showOptionTags[ACCESS_USAGES.CA] : !!showOptionTags; + }, [showOptionTags]); + const showOptionTagForNotification = useMemo(() => { + return typeof showOptionTags === "object" ? !!showOptionTags[ACCESS_USAGES.NOTIFICATION] : !!showOptionTags; + }, [showOptionTags]); + const renderOption = (key: string) => { - const provider = accessProvidersMap.get(key); + const provider = accessProvidersMap.get(key) ?? ({ type: "", name: "", icon: "", usages: [] } as unknown as AccessProvider); return (
- - - {t(provider?.name ?? "")} + + + {t(provider.name)} -
- {provider?.usages?.includes(ACCESS_USAGES.APPLY) && ( - <> - {t("access.props.provider.usage.dns")} - - )} - {provider?.usages?.includes(ACCESS_USAGES.DEPLOY) && ( - <> - {t("access.props.provider.usage.host")} - - )} -
+ {showOptionTags && ( +
+ + {t("access.props.provider.usage.dns")} + + + {t("access.props.provider.usage.hosting")} + + + {t("access.props.provider.usage.ca")} + + + {t("access.props.provider.usage.notification")} + +
+ )}
); }; diff --git a/ui/src/components/provider/ApplyCAProviderSelect.tsx b/ui/src/components/provider/ApplyCAProviderSelect.tsx new file mode 100644 index 00000000..fdedac9b --- /dev/null +++ b/ui/src/components/provider/ApplyCAProviderSelect.tsx @@ -0,0 +1,83 @@ +import { memo, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Avatar, Select, type SelectProps, Space, Typography } from "antd"; + +import { type ApplyCAProvider, applyCAProvidersMap } from "@/domain/provider"; + +export type ApplyCAProviderSelectProps = Omit< + SelectProps, + "filterOption" | "filterSort" | "labelRender" | "options" | "optionFilterProp" | "optionLabelProp" | "optionRender" +> & { + filter?: (record: ApplyCAProvider) => boolean; +}; + +const ApplyCAProviderSelect = ({ filter, ...props }: ApplyCAProviderSelectProps) => { + const { t } = useTranslation(); + + const [options, setOptions] = useState>([]); + useEffect(() => { + const allItems = Array.from(applyCAProvidersMap.values()); + const filteredItems = filter != null ? allItems.filter(filter) : allItems; + setOptions([ + { + key: "", + value: "", + label: "provider.default_ca_provider.label", + data: {} as ApplyCAProvider, + }, + ...filteredItems.map((item) => ({ + key: item.type, + value: item.type, + label: t(item.name), + data: item, + })), + ]); + }, [filter]); + + const renderOption = (key: string) => { + if (key === "") { + return ( + + + {t("provider.default_ca_provider.label")} + + + ); + } + + const provider = applyCAProvidersMap.get(key); + return ( + + + + {t(provider?.name ?? "")} + + + ); + }; + + return ( + ({ @@ -364,6 +486,9 @@ const ApplyNodeConfigForm = forwardRef { formInst.setFieldValue("nameservers", e.target.value); }} + onClear={() => { + formInst.setFieldValue("nameservers", undefined); + }} />
diff --git a/ui/src/components/workflow/node/DeployNodeConfigForm.tsx b/ui/src/components/workflow/node/DeployNodeConfigForm.tsx index 948f59f2..33d1012c 100644 --- a/ui/src/components/workflow/node/DeployNodeConfigForm.tsx +++ b/ui/src/components/workflow/node/DeployNodeConfigForm.tsx @@ -7,8 +7,8 @@ import { z } from "zod"; import AccessEditModal from "@/components/access/AccessEditModal"; import AccessSelect from "@/components/access/AccessSelect"; -import DeployProviderPicker from "@/components/provider/DeployProviderPicker"; -import DeployProviderSelect from "@/components/provider/DeployProviderSelect"; +import DeployProviderPicker from "@/components/provider/DeployProviderPicker.tsx"; +import DeployProviderSelect from "@/components/provider/DeployProviderSelect.tsx"; import Show from "@/components/Show"; import { ACCESS_USAGES, DEPLOY_PROVIDERS, accessProvidersMap, deployProvidersMap } from "@/domain/provider"; import { type WorkflowNode, type WorkflowNodeConfigForDeploy } from "@/domain/workflow"; @@ -125,8 +125,14 @@ const DeployNodeConfigForm = forwardRef { + if (!fieldProvider) return true; + + const provider = deployProvidersMap.get(fieldProvider); + return !!provider?.builtin || !!v; + }, t("workflow_node.deploy.form.provider_access.placeholder")), + providerConfig: z.any().nullish(), skipOnLastSucceeded: z.boolean().nullish(), }); const formRule = createSchemaFieldRule(formSchema); @@ -137,6 +143,17 @@ const DeployNodeConfigForm = forwardRef { + // 内置的部署提供商(如本地部署)无需显示授权信息字段 + if (fieldProvider) { + const provider = deployProvidersMap.get(fieldProvider); + setShowProviderAccess(!provider?.builtin); + } else { + setShowProviderAccess(false); + } + }, [fieldProvider]); + const [nestedFormInst] = Form.useForm(); const nestedFormName = useAntdFormName({ form: nestedFormInst, name: "workflowNodeDeployConfigFormProviderConfigForm" }); const nestedFormEl = useMemo(() => { @@ -292,7 +309,7 @@ const DeployNodeConfigForm = forwardRef { + const handleProviderSelect = (value?: string | undefined) => { if (fieldProvider === value) return; // 切换部署目标时重置表单,避免其他部署目标的配置字段影响当前部署目标 @@ -310,7 +327,7 @@ const DeployNodeConfigForm = forwardRef - +