From 122d766cab7304ed28d4ff23e03063db0984cdea Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Fri, 16 May 2025 21:40:40 +0800 Subject: [PATCH] feat: new ca provider: custom acme ca --- internal/applicant/acme_ca.go | 33 ++++---- internal/applicant/acme_user.go | 54 ++++++++++--- internal/applicant/applicant.go | 33 +++++--- internal/applicant/providers.go | 1 + internal/domain/access.go | 6 ++ internal/domain/provider.go | 3 +- .../workflow/node-processor/apply_node.go | 4 +- ui/public/imgs/providers/acmeca.svg | 1 + ui/src/components/access/AccessForm.tsx | 3 + .../access/AccessFormACMECAConfig.tsx | 77 +++++++++++++++++++ ui/src/domain/access.ts | 7 ++ ui/src/domain/provider.ts | 4 + ui/src/i18n/locales/en/nls.access.json | 7 ++ ui/src/i18n/locales/en/nls.provider.json | 3 +- ui/src/i18n/locales/zh/nls.access.json | 7 ++ ui/src/i18n/locales/zh/nls.provider.json | 3 +- 16 files changed, 206 insertions(+), 40 deletions(-) create mode 100644 ui/public/imgs/providers/acmeca.svg create mode 100644 ui/src/components/access/AccessFormACMECAConfig.tsx diff --git a/internal/applicant/acme_ca.go b/internal/applicant/acme_ca.go index 67b7693e..36c0a0a4 100644 --- a/internal/applicant/acme_ca.go +++ b/internal/applicant/acme_ca.go @@ -3,25 +3,26 @@ package applicant import "github.com/usual2970/certimate/internal/domain" const ( - sslProviderLetsEncrypt = string(domain.CAProviderTypeLetsEncrypt) - sslProviderLetsEncryptStaging = string(domain.CAProviderTypeLetsEncryptStaging) - sslProviderBuypass = string(domain.CAProviderTypeBuypass) - sslProviderGoogleTrustServices = string(domain.CAProviderTypeGoogleTrustServices) - sslProviderSSLCom = string(domain.CAProviderTypeSSLCom) - sslProviderZeroSSL = string(domain.CAProviderTypeZeroSSL) + caLetsEncrypt = string(domain.CAProviderTypeLetsEncrypt) + caLetsEncryptStaging = string(domain.CAProviderTypeLetsEncryptStaging) + caBuypass = string(domain.CAProviderTypeBuypass) + caGoogleTrustServices = string(domain.CAProviderTypeGoogleTrustServices) + caSSLCom = string(domain.CAProviderTypeSSLCom) + caZeroSSL = string(domain.CAProviderTypeZeroSSL) + caCustom = string(domain.CAProviderTypeACMECA) - sslProviderDefault = sslProviderLetsEncrypt + caDefault = caLetsEncrypt ) -var sslProviderUrls = map[string]string{ - 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", +var caDirUrls = map[string]string{ + caLetsEncrypt: "https://acme-v02.api.letsencrypt.org/directory", + caLetsEncryptStaging: "https://acme-staging-v02.api.letsencrypt.org/directory", + caBuypass: "https://api.buypass.com/acme/directory", + caGoogleTrustServices: "https://dv.acme-v02.api.pki.goog/directory", + caSSLCom: "https://acme.ssl.com/sslcom-dv-rsa", + caSSLCom + "RSA": "https://acme.ssl.com/sslcom-dv-rsa", + caSSLCom + "ECC": "https://acme.ssl.com/sslcom-dv-ecc", + caZeroSSL: "https://acme.zerossl.com/v2/DV90", } type acmeSSLProviderConfig struct { diff --git a/internal/applicant/acme_user.go b/internal/applicant/acme_user.go index 29ac80cd..e6e13cb7 100644 --- a/internal/applicant/acme_user.go +++ b/internal/applicant/acme_user.go @@ -7,6 +7,7 @@ import ( "crypto/elliptic" "crypto/rand" "fmt" + "strings" "github.com/go-acme/lego/v4/lego" "github.com/go-acme/lego/v4/registration" @@ -19,22 +20,31 @@ import ( ) type acmeUser struct { - CA string - Email string + // 证书颁发机构标识。 + // 通常等同于 [CAProviderType] 的值。 + // 对于自定义 ACME CA,值为 "custom#{access_id}"。 + CA string + // 邮箱。 + Email string + // 注册信息。 Registration *registration.Resource + // CSR 私钥。 privkey string } -func newAcmeUser(ca, email string) (*acmeUser, error) { +func newAcmeUser(ca, caAccessId, email string) (*acmeUser, error) { repo := repository.NewAcmeAccountRepository() applyUser := &acmeUser{ CA: ca, Email: email, } + if ca == caCustom { + applyUser.CA = fmt.Sprintf("%s#%s", ca, caAccessId) + } - acmeAccount, err := repo.GetByCAAndEmail(ca, email) + acmeAccount, err := repo.GetByCAAndEmail(applyUser.CA, applyUser.Email) if err != nil { key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { @@ -73,6 +83,10 @@ func (u *acmeUser) hasRegistration() bool { return u.Registration != nil } +func (u *acmeUser) getCAProvider() string { + return strings.Split(u.CA, "#")[0] +} + func (u *acmeUser) getPrivateKeyPEM() string { return u.privkey } @@ -94,16 +108,16 @@ func registerAcmeUserWithSingleFlight(client *lego.Client, user *acmeUser, userR func registerAcmeUser(client *lego.Client, user *acmeUser, userRegisterOptions map[string]any) (*registration.Resource, error) { var reg *registration.Resource var err error - switch user.CA { - case sslProviderLetsEncrypt, sslProviderLetsEncryptStaging: + switch user.getCAProvider() { + case caLetsEncrypt, caLetsEncryptStaging: reg, err = client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) - case sslProviderBuypass: + case caBuypass: { reg, err = client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) } - case sslProviderGoogleTrustServices: + case caGoogleTrustServices: { access := domain.AccessConfigForGoogleTrustServices{} if err := maputil.Populate(userRegisterOptions, &access); err != nil { @@ -117,7 +131,7 @@ func registerAcmeUser(client *lego.Client, user *acmeUser, userRegisterOptions m }) } - case sslProviderSSLCom: + case caSSLCom: { access := domain.AccessConfigForSSLCom{} if err := maputil.Populate(userRegisterOptions, &access); err != nil { @@ -131,7 +145,7 @@ func registerAcmeUser(client *lego.Client, user *acmeUser, userRegisterOptions m }) } - case sslProviderZeroSSL: + case caZeroSSL: { access := domain.AccessConfigForZeroSSL{} if err := maputil.Populate(userRegisterOptions, &access); err != nil { @@ -145,6 +159,26 @@ func registerAcmeUser(client *lego.Client, user *acmeUser, userRegisterOptions m }) } + case caCustom: + { + access := domain.AccessConfigForACMECA{} + if err := maputil.Populate(userRegisterOptions, &access); err != nil { + return nil, fmt.Errorf("failed to populate provider access config: %w", err) + } + + if access.EabKid == "" && access.EabHmacKey == "" { + reg, err = client.Registration.Register(registration.RegisterOptions{ + TermsOfServiceAgreed: true, + }) + } else { + reg, err = client.Registration.RegisterWithExternalAccountBinding(registration.RegisterEABOptions{ + TermsOfServiceAgreed: true, + Kid: access.EabKid, + HmacEncoded: access.EabHmacKey, + }) + } + } + default: err = fmt.Errorf("unsupported ca provider '%s'", user.CA) } diff --git a/internal/applicant/applicant.go b/internal/applicant/applicant.go index e9ed4cb1..6d17e940 100644 --- a/internal/applicant/applicant.go +++ b/internal/applicant/applicant.go @@ -20,12 +20,13 @@ import ( "golang.org/x/time/rate" "github.com/usual2970/certimate/internal/domain" + maputil "github.com/usual2970/certimate/internal/pkg/utils/map" sliceutil "github.com/usual2970/certimate/internal/pkg/utils/slice" "github.com/usual2970/certimate/internal/repository" ) type ApplyResult struct { - CertificateFullChain string + FullChainCertificate string IssuerCertificate string PrivateKey string ACMEAccountUrl string @@ -81,6 +82,7 @@ func NewWithWorkflowNode(config ApplicantWithWorkflowNodeConfig) (Applicant, err 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.CAProviderAccessId = access.Id options.CAProviderAccessConfig = access.Config } } @@ -91,13 +93,13 @@ func NewWithWorkflowNode(config ApplicantWithWorkflowNodeConfig) (Applicant, err sslProviderConfig := &acmeSSLProviderConfig{ Config: make(map[domain.CAProviderType]map[string]any), - Provider: sslProviderDefault, + Provider: caDefault, } if settings != nil { if err := json.Unmarshal([]byte(settings.Content), sslProviderConfig); err != nil { return nil, err } else if sslProviderConfig.Provider == "" { - sslProviderConfig.Provider = sslProviderDefault + sslProviderConfig.Provider = caDefault } } @@ -163,7 +165,7 @@ func getLimiter(key string) *rate.Limiter { } func applyUseLego(legoProvider challenge.Provider, options *applicantProviderOptions) (*ApplyResult, error) { - user, err := newAcmeUser(string(options.CAProvider), options.ContactEmail) + user, err := newAcmeUser(string(options.CAProvider), options.CAProviderAccessId, options.ContactEmail) if err != nil { return nil, err } @@ -175,13 +177,26 @@ func applyUseLego(legoProvider challenge.Provider, options *applicantProviderOpt // Create an ACME client config config := lego.NewConfig(user) config.Certificate.KeyType = parseLegoKeyAlgorithm(domain.CertificateKeyAlgorithmType(options.KeyAlgorithm)) - config.CADirURL = sslProviderUrls[user.CA] - if user.CA == sslProviderSSLCom { + switch user.getCAProvider() { + case caSSLCom: if strings.HasPrefix(options.KeyAlgorithm, "RSA") { - config.CADirURL = sslProviderUrls[sslProviderSSLCom+"RSA"] + config.CADirURL = caDirUrls[caSSLCom+"RSA"] } else if strings.HasPrefix(options.KeyAlgorithm, "EC") { - config.CADirURL = sslProviderUrls[sslProviderSSLCom+"ECC"] + config.CADirURL = caDirUrls[caSSLCom+"ECC"] + } else { + config.CADirURL = caDirUrls[caSSLCom] } + + case caCustom: + caDirURL := maputil.GetString(options.CAProviderAccessConfig, "endpoint") + if caDirURL != "" { + config.CADirURL = caDirURL + } else { + return nil, fmt.Errorf("invalid ca provider endpoint") + } + + default: + config.CADirURL = caDirUrls[user.CA] } // Create an ACME client @@ -229,7 +244,7 @@ func applyUseLego(legoProvider challenge.Provider, options *applicantProviderOpt } return &ApplyResult{ - CertificateFullChain: strings.TrimSpace(string(certResource.Certificate)), + FullChainCertificate: strings.TrimSpace(string(certResource.Certificate)), IssuerCertificate: strings.TrimSpace(string(certResource.IssuerCertificate)), PrivateKey: strings.TrimSpace(string(certResource.PrivateKey)), ACMEAccountUrl: user.Registration.URI, diff --git a/internal/applicant/providers.go b/internal/applicant/providers.go index 90a3cf72..bf27e7e3 100644 --- a/internal/applicant/providers.go +++ b/internal/applicant/providers.go @@ -48,6 +48,7 @@ type applicantProviderOptions struct { ProviderAccessConfig map[string]any ProviderExtendedConfig map[string]any CAProvider domain.CAProviderType + CAProviderAccessId string CAProviderAccessConfig map[string]any CAProviderExtendedConfig map[string]any KeyAlgorithm string diff --git a/internal/domain/access.go b/internal/domain/access.go index 9ab18b9f..0321cb41 100644 --- a/internal/domain/access.go +++ b/internal/domain/access.go @@ -22,6 +22,12 @@ type AccessConfigFor1Panel struct { AllowInsecureConnections bool `json:"allowInsecureConnections,omitempty"` } +type AccessConfigForACMECA struct { + Endpoint string `json:"endpoint"` + EabKid string `json:"eabKid,omitempty"` + EabHmacKey string `json:"eabHmacKey,omitempty"` +} + type AccessConfigForACMEHttpReq struct { Endpoint string `json:"endpoint"` Mode string `json:"mode,omitempty"` diff --git a/internal/domain/provider.go b/internal/domain/provider.go index 4a0c05dd..d4d9af2e 100644 --- a/internal/domain/provider.go +++ b/internal/domain/provider.go @@ -10,7 +10,7 @@ type AccessProviderType string */ const ( AccessProviderType1Panel = AccessProviderType("1panel") - AccessProviderTypeACMECA = AccessProviderType("acmeca") // ACME CA(预留) + AccessProviderTypeACMECA = AccessProviderType("acmeca") AccessProviderTypeACMEHttpReq = AccessProviderType("acmehttpreq") AccessProviderTypeAkamai = AccessProviderType("akamai") // Akamai(预留) AccessProviderTypeAliyun = AccessProviderType("aliyun") @@ -91,6 +91,7 @@ type CAProviderType string NOTICE: If you add new constant, please keep ASCII order. */ const ( + CAProviderTypeACMECA = CAProviderType(AccessProviderTypeACMECA) CAProviderTypeBuypass = CAProviderType(AccessProviderTypeBuypass) CAProviderTypeGoogleTrustServices = CAProviderType(AccessProviderTypeGoogleTrustServices) CAProviderTypeLetsEncrypt = CAProviderType(AccessProviderTypeLetsEncrypt) diff --git a/internal/workflow/node-processor/apply_node.go b/internal/workflow/node-processor/apply_node.go index 97b7575d..ff8c573d 100644 --- a/internal/workflow/node-processor/apply_node.go +++ b/internal/workflow/node-processor/apply_node.go @@ -66,14 +66,14 @@ func (n *applyNode) Process(ctx context.Context) error { } // 解析证书并生成实体 - certX509, err := certutil.ParseCertificateFromPEM(applyResult.CertificateFullChain) + certX509, err := certutil.ParseCertificateFromPEM(applyResult.FullChainCertificate) if err != nil { n.logger.Warn("failed to parse certificate, may be the CA responded error") return err } certificate := &domain.Certificate{ Source: domain.CertificateSourceTypeWorkflow, - Certificate: applyResult.CertificateFullChain, + Certificate: applyResult.FullChainCertificate, PrivateKey: applyResult.PrivateKey, IssuerCertificate: applyResult.IssuerCertificate, ACMEAccountUrl: applyResult.ACMEAccountUrl, diff --git a/ui/public/imgs/providers/acmeca.svg b/ui/public/imgs/providers/acmeca.svg new file mode 100644 index 00000000..260530c9 --- /dev/null +++ b/ui/public/imgs/providers/acmeca.svg @@ -0,0 +1 @@ + diff --git a/ui/src/components/access/AccessForm.tsx b/ui/src/components/access/AccessForm.tsx index 3e31dad4..4981a045 100644 --- a/ui/src/components/access/AccessForm.tsx +++ b/ui/src/components/access/AccessForm.tsx @@ -12,6 +12,7 @@ import { ACCESS_PROVIDERS, ACCESS_USAGES, type AccessProvider } from "@/domain/p import { useAntdForm, useAntdFormName } from "@/hooks"; import AccessForm1PanelConfig from "./AccessForm1PanelConfig"; +import AccessFormACMECAConfig from "./AccessFormACMECAConfig"; import AccessFormACMEHttpReqConfig from "./AccessFormACMEHttpReqConfig"; import AccessFormAliyunConfig from "./AccessFormAliyunConfig"; import AccessFormAWSConfig from "./AccessFormAWSConfig"; @@ -177,6 +178,8 @@ const AccessForm = forwardRef(({ className, switch (fieldProvider) { case ACCESS_PROVIDERS["1PANEL"]: return ; + case ACCESS_PROVIDERS.ACMECA: + return ; case ACCESS_PROVIDERS.ACMEHTTPREQ: return ; case ACCESS_PROVIDERS.ALIYUN: diff --git a/ui/src/components/access/AccessFormACMECAConfig.tsx b/ui/src/components/access/AccessFormACMECAConfig.tsx new file mode 100644 index 00000000..8c9eb2ac --- /dev/null +++ b/ui/src/components/access/AccessFormACMECAConfig.tsx @@ -0,0 +1,77 @@ +import { useTranslation } from "react-i18next"; +import { Form, type FormInstance, Input, Select } from "antd"; +import { createSchemaFieldRule } from "antd-zod"; +import { z } from "zod"; + +import { type AccessConfigForACMECA } from "@/domain/access"; + +type AccessFormACMECAConfigFieldValues = Nullish; + +export type AccessFormACMECAConfigProps = { + form: FormInstance; + formName: string; + disabled?: boolean; + initialValues?: AccessFormACMECAConfigFieldValues; + onValuesChange?: (values: AccessFormACMECAConfigFieldValues) => void; +}; + +const initFormModel = (): AccessFormACMECAConfigFieldValues => { + return { + endpoint: "https://example.com/acme/directory", + }; +}; + +const AccessFormACMECAConfig = ({ form: formInst, formName, disabled, initialValues, onValuesChange }: AccessFormACMECAConfigProps) => { + const { t } = useTranslation(); + + const formSchema = z.object({ + endpoint: z.string().url(t("common.errmsg.url_invalid")), + eabKid: z.string().trim().nullish(), + eabHmacKey: z.string().trim().nullish(), + }); + const formRule = createSchemaFieldRule(formSchema); + + const handleFormChange = (_: unknown, values: z.infer) => { + onValuesChange?.(values); + }; + + return ( +
+ } + > + + + + } + > + + + + } + > + + +
+ ); +}; + +export default AccessFormACMECAConfig; diff --git a/ui/src/domain/access.ts b/ui/src/domain/access.ts index fe912543..c676b5f7 100644 --- a/ui/src/domain/access.ts +++ b/ui/src/domain/access.ts @@ -7,6 +7,7 @@ export interface AccessModel extends BaseModel { */ Record & ( | AccessConfigFor1Panel + | AccessConfigForACMECA | AccessConfigForACMEHttpReq | AccessConfigForAliyun | AccessConfigForAWS @@ -75,6 +76,12 @@ export type AccessConfigFor1Panel = { allowInsecureConnections?: boolean; }; +export type AccessConfigForACMECA = { + endpoint: string; + eabKid?: string; + eabHmacKey?: string; +}; + export type AccessConfigForACMEHttpReq = { endpoint: string; mode?: string; diff --git a/ui/src/domain/provider.ts b/ui/src/domain/provider.ts index 3d0678ba..2c6f0f0f 100644 --- a/ui/src/domain/provider.ts +++ b/ui/src/domain/provider.ts @@ -5,6 +5,7 @@ */ export const ACCESS_PROVIDERS = Object.freeze({ ["1PANEL"]: "1panel", + ACMECA: "acmeca", ACMEHTTPREQ: "acmehttpreq", ALIYUN: "aliyun", AWS: "aws", @@ -153,6 +154,7 @@ export const accessProvidersMap: Map = new [CA_PROVIDERS.GOOGLETRUSTSERVICES], [CA_PROVIDERS.SSLCOM], [CA_PROVIDERS.ZEROSSL], + [CA_PROVIDERS.ACMECA], ].map(([type, builtin]) => [ type, { diff --git a/ui/src/i18n/locales/en/nls.access.json b/ui/src/i18n/locales/en/nls.access.json index b3f9167f..4b178ad4 100644 --- a/ui/src/i18n/locales/en/nls.access.json +++ b/ui/src/i18n/locales/en/nls.access.json @@ -44,6 +44,13 @@ "access.form.1panel_allow_insecure_conns.label": "Insecure SSL/TLS connections", "access.form.1panel_allow_insecure_conns.switch.on": "Allow", "access.form.1panel_allow_insecure_conns.switch.off": "Disallow", + "access.form.acmeca_endpoint.label": "Endpoint", + "access.form.acmeca_endpoint.placeholder": "Please enter endpoint", + "access.form.acmeca_endpoint.tooltip": "For more information, see https://datatracker.ietf.org/doc/html/rfc8555#section-7.1.1", + "access.form.acmeca_eab_kid.label": "ACME EAB KID (Optional)", + "access.form.acmeca_eab_kid.placeholder": "Please enter ACME EAB KID", + "access.form.acmeca_eab_hmac_key.label": "ACME EAB HMAC key (Optional)", + "access.form.acmeca_eab_hmac_key.placeholder": "Please enter ACME EAB HMAC key", "access.form.acmehttpreq_endpoint.label": "Endpoint", "access.form.acmehttpreq_endpoint.placeholder": "Please enter endpoint", "access.form.acmehttpreq_endpoint.tooltip": "For more information, see https://go-acme.github.io/lego/dns/httpreq/", diff --git a/ui/src/i18n/locales/en/nls.provider.json b/ui/src/i18n/locales/en/nls.provider.json index 13571036..dad0ae6f 100644 --- a/ui/src/i18n/locales/en/nls.provider.json +++ b/ui/src/i18n/locales/en/nls.provider.json @@ -2,7 +2,8 @@ "provider.1panel": "1Panel", "provider.1panel.console": "1Panel - Console", "provider.1panel.site": "1Panel - Website", - "provider.acmehttpreq": "Http Request (ACME Proxy)", + "provider.acmeca": "ACME Custom CA Endpoint", + "provider.acmehttpreq": "ACME Custom HTTP Endpoint", "provider.aliyun": "Alibaba Cloud", "provider.aliyun.alb": "Alibaba Cloud - ALB (Application Load Balancer)", "provider.aliyun.apigw": "Alibaba Cloud - API Gateway", diff --git a/ui/src/i18n/locales/zh/nls.access.json b/ui/src/i18n/locales/zh/nls.access.json index 5e3444fe..1ca78d07 100644 --- a/ui/src/i18n/locales/zh/nls.access.json +++ b/ui/src/i18n/locales/zh/nls.access.json @@ -44,6 +44,13 @@ "access.form.1panel_allow_insecure_conns.label": "忽略 SSL/TLS 证书错误", "access.form.1panel_allow_insecure_conns.switch.on": "允许", "access.form.1panel_allow_insecure_conns.switch.off": "不允许", + "access.form.acmeca_endpoint.label": "服务端点", + "access.form.acmeca_endpoint.placeholder": "请输入服务端点", + "access.form.acmeca_endpoint.tooltip": "这是什么?请参阅 https://datatracker.ietf.org/doc/html/rfc8555#section-7.1.1", + "access.form.acmeca_eab_kid.label": "ACME EAB KID(可选)", + "access.form.acmeca_eab_kid.placeholder": "请输入 ACME EAB KID", + "access.form.acmeca_eab_hmac_key.label": "ACME EAB HMAC Key(可选)", + "access.form.acmeca_eab_hmac_key.placeholder": "请输入 ACME EAB HMAC Key", "access.form.acmehttpreq_endpoint.label": "服务端点", "access.form.acmehttpreq_endpoint.placeholder": "请输入服务端点", "access.form.acmehttpreq_endpoint.tooltip": "这是什么?请参阅 https://go-acme.github.io/lego/dns/httpreq/", diff --git a/ui/src/i18n/locales/zh/nls.provider.json b/ui/src/i18n/locales/zh/nls.provider.json index bd04cc43..e7c6ea31 100644 --- a/ui/src/i18n/locales/zh/nls.provider.json +++ b/ui/src/i18n/locales/zh/nls.provider.json @@ -2,7 +2,8 @@ "provider.1panel": "1Panel", "provider.1panel.console": "1Panel - 面板", "provider.1panel.site": "1Panel - 网站", - "provider.acmehttpreq": "Http Request (ACME Proxy)", + "provider.acmeca": "ACME 自定义 CA 端点", + "provider.acmehttpreq": "ACME 自定义 HTTP 端点", "provider.aliyun": "阿里云", "provider.aliyun.alb": "阿里云 - 应用型负载均衡 ALB", "provider.aliyun.apigw": "阿里云 - API 网关",