From 7210f638840f4f6896276844e91c9991f283bd80 Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Tue, 3 Jun 2025 15:54:13 +0800 Subject: [PATCH 1/5] feat: new acme dns-01 provider: constellix --- internal/applicant/providers.go | 17 +++++ internal/domain/access.go | 5 ++ internal/domain/provider.go | 2 + .../lego-providers/constellix/constellix.go | 38 ++++++++++ ui/public/imgs/providers/constellix.png | Bin 0 -> 8081 bytes ui/src/components/access/AccessForm.tsx | 3 + .../access/AccessFormConstellixConfig.tsx | 67 ++++++++++++++++++ ui/src/domain/access.ts | 6 ++ ui/src/domain/provider.ts | 4 ++ ui/src/i18n/locales/en/nls.access.json | 6 ++ ui/src/i18n/locales/en/nls.provider.json | 1 + ui/src/i18n/locales/zh/nls.access.json | 6 ++ ui/src/i18n/locales/zh/nls.provider.json | 1 + 13 files changed, 156 insertions(+) create mode 100644 internal/pkg/core/applicant/acme-dns-01/lego-providers/constellix/constellix.go create mode 100644 ui/public/imgs/providers/constellix.png create mode 100644 ui/src/components/access/AccessFormConstellixConfig.tsx diff --git a/internal/applicant/providers.go b/internal/applicant/providers.go index 98561daf..ba4fadef 100644 --- a/internal/applicant/providers.go +++ b/internal/applicant/providers.go @@ -16,6 +16,7 @@ import ( pCloudflare "github.com/usual2970/certimate/internal/pkg/core/applicant/acme-dns-01/lego-providers/cloudflare" pClouDNS "github.com/usual2970/certimate/internal/pkg/core/applicant/acme-dns-01/lego-providers/cloudns" pCMCCCloud "github.com/usual2970/certimate/internal/pkg/core/applicant/acme-dns-01/lego-providers/cmcccloud" + pConstellix "github.com/usual2970/certimate/internal/pkg/core/applicant/acme-dns-01/lego-providers/constellix" pDeSEC "github.com/usual2970/certimate/internal/pkg/core/applicant/acme-dns-01/lego-providers/desec" pDigitalOcean "github.com/usual2970/certimate/internal/pkg/core/applicant/acme-dns-01/lego-providers/digitalocean" pDNSLA "github.com/usual2970/certimate/internal/pkg/core/applicant/acme-dns-01/lego-providers/dnsla" @@ -234,6 +235,22 @@ func createApplicantProvider(options *applicantProviderOptions) (challenge.Provi return applicant, err } + case domain.ACMEDns01ProviderTypeConstellix: + { + access := domain.AccessConfigForConstellix{} + if err := maputil.Populate(options.ProviderAccessConfig, &access); err != nil { + return nil, fmt.Errorf("failed to populate provider access config: %w", err) + } + + applicant, err := pConstellix.NewChallengeProvider(&pConstellix.ChallengeProviderConfig{ + ApiKey: access.ApiKey, + SecretKey: access.SecretKey, + DnsPropagationTimeout: options.DnsPropagationTimeout, + DnsTTL: options.DnsTTL, + }) + return applicant, err + } + case domain.ACMEDns01ProviderTypeDeSEC: { access := domain.AccessConfigForDeSEC{} diff --git a/internal/domain/access.go b/internal/domain/access.go index 0ed9f0ee..274f2fd0 100644 --- a/internal/domain/access.go +++ b/internal/domain/access.go @@ -109,6 +109,11 @@ type AccessConfigForCMCCCloud struct { AccessKeySecret string `json:"accessKeySecret"` } +type AccessConfigForConstellix struct { + ApiKey string `json:"apiKey"` + SecretKey string `json:"secretKey"` +} + type AccessConfigForDeSEC struct { Token string `json:"token"` } diff --git a/internal/domain/provider.go b/internal/domain/provider.go index 55f8b2af..dd9663f2 100644 --- a/internal/domain/provider.go +++ b/internal/domain/provider.go @@ -28,6 +28,7 @@ const ( AccessProviderTypeCloudflare = AccessProviderType("cloudflare") AccessProviderTypeClouDNS = AccessProviderType("cloudns") AccessProviderTypeCMCCCloud = AccessProviderType("cmcccloud") + AccessProviderTypeConstellix = AccessProviderType("constellix") AccessProviderTypeCTCCCloud = AccessProviderType("ctcccloud") // 天翼云(预留) AccessProviderTypeCUCCCloud = AccessProviderType("cucccloud") // 联通云(预留) AccessProviderTypeDeSEC = AccessProviderType("desec") @@ -131,6 +132,7 @@ const ( ACMEDns01ProviderTypeCloudflare = ACMEDns01ProviderType(AccessProviderTypeCloudflare) ACMEDns01ProviderTypeClouDNS = ACMEDns01ProviderType(AccessProviderTypeClouDNS) ACMEDns01ProviderTypeCMCCCloud = ACMEDns01ProviderType(AccessProviderTypeCMCCCloud) + ACMEDns01ProviderTypeConstellix = ACMEDns01ProviderType(AccessProviderTypeConstellix) ACMEDns01ProviderTypeDeSEC = ACMEDns01ProviderType(AccessProviderTypeDeSEC) ACMEDns01ProviderTypeDigitalOcean = ACMEDns01ProviderType(AccessProviderTypeDigitalOcean) ACMEDns01ProviderTypeDNSLA = ACMEDns01ProviderType(AccessProviderTypeDNSLA) diff --git a/internal/pkg/core/applicant/acme-dns-01/lego-providers/constellix/constellix.go b/internal/pkg/core/applicant/acme-dns-01/lego-providers/constellix/constellix.go new file mode 100644 index 00000000..12e7d615 --- /dev/null +++ b/internal/pkg/core/applicant/acme-dns-01/lego-providers/constellix/constellix.go @@ -0,0 +1,38 @@ +package cloudns + +import ( + "time" + + "github.com/go-acme/lego/v4/challenge" + "github.com/go-acme/lego/v4/providers/dns/constellix" +) + +type ChallengeProviderConfig struct { + ApiKey string `json:"apiKey"` + SecretKey string `json:"secretKey"` + DnsPropagationTimeout int32 `json:"dnsPropagationTimeout,omitempty"` + DnsTTL int32 `json:"dnsTTL,omitempty"` +} + +func NewChallengeProvider(config *ChallengeProviderConfig) (challenge.Provider, error) { + if config == nil { + panic("config is nil") + } + + providerConfig := constellix.NewDefaultConfig() + providerConfig.APIKey = config.ApiKey + providerConfig.SecretKey = config.SecretKey + if config.DnsPropagationTimeout != 0 { + providerConfig.PropagationTimeout = time.Duration(config.DnsPropagationTimeout) * time.Second + } + if config.DnsTTL != 0 { + providerConfig.TTL = int(config.DnsTTL) + } + + provider, err := constellix.NewDNSProviderConfig(providerConfig) + if err != nil { + return nil, err + } + + return provider, nil +} diff --git a/ui/public/imgs/providers/constellix.png b/ui/public/imgs/providers/constellix.png new file mode 100644 index 0000000000000000000000000000000000000000..71a8722a33e152add513cbb918b437cbb25c4f5f GIT binary patch literal 8081 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 className, return ; case ACCESS_PROVIDERS.CMCCCLOUD: return ; + case ACCESS_PROVIDERS.CONSTELLIX: + return ; case ACCESS_PROVIDERS.DESEC: return ; case ACCESS_PROVIDERS.DIGITALOCEAN: diff --git a/ui/src/components/access/AccessFormConstellixConfig.tsx b/ui/src/components/access/AccessFormConstellixConfig.tsx new file mode 100644 index 00000000..5966828a --- /dev/null +++ b/ui/src/components/access/AccessFormConstellixConfig.tsx @@ -0,0 +1,67 @@ +import { useTranslation } from "react-i18next"; +import { Form, type FormInstance, Input } from "antd"; +import { createSchemaFieldRule } from "antd-zod"; +import { z } from "zod"; +import { type AccessConfigForConstellix } from "@/domain/access"; + +type AccessFormConstellixConfigFieldValues = Nullish; + +export type AccessFormConstellixConfigProps = { + form: FormInstance; + formName: string; + disabled?: boolean; + initialValues?: AccessFormConstellixConfigFieldValues; + onValuesChange?: (values: AccessFormConstellixConfigFieldValues) => void; +}; + +const initFormModel = (): AccessFormConstellixConfigFieldValues => { + return { + apiKey: "", + secretKey: "", + }; +}; + +const AccessFormConstellixConfig = ({ form: formInst, formName, disabled, initialValues, onValuesChange: onValuesChange }: AccessFormConstellixConfigProps) => { + const { t } = useTranslation(); + + const formSchema = z.object({ + apiKey: z.string().trim().nonempty(t("access.form.constellix_api_key.placeholder")), + secretKey: z.string().trim().nonempty(t("access.form.constellix_secret_key.placeholder")), + }); + const formRule = createSchemaFieldRule(formSchema); + + const handleFormChange = (_: unknown, values: z.infer) => { + onValuesChange?.(values); + }; + + return ( +
+ } + > + + + + } + > + + +
+ ); +}; + +export default AccessFormConstellixConfig; diff --git a/ui/src/domain/access.ts b/ui/src/domain/access.ts index fe9f12e3..6da7c90b 100644 --- a/ui/src/domain/access.ts +++ b/ui/src/domain/access.ts @@ -23,6 +23,7 @@ export interface AccessModel extends BaseModel { | AccessConfigForCloudflare | AccessConfigForClouDNS | AccessConfigForCMCCCloud + | AccessConfigForConstellix | AccessConfigForDeSEC | AccessConfigForDigitalOcean | AccessConfigForDingTalkBot @@ -172,6 +173,11 @@ export type AccessConfigForCMCCCloud = { accessKeySecret: string; }; +export type AccessConfigForConstellix = { + apiKey: string; + secretKey: string; +}; + export type AccessConfigForDeSEC = { token: string; }; diff --git a/ui/src/domain/provider.ts b/ui/src/domain/provider.ts index bb550691..5bd1439b 100644 --- a/ui/src/domain/provider.ts +++ b/ui/src/domain/provider.ts @@ -22,6 +22,7 @@ export const ACCESS_PROVIDERS = Object.freeze({ CLOUDFLARE: "cloudflare", CLOUDNS: "cloudns", CMCCCLOUD: "cmcccloud", + CONSTELLIX: "constellix", DESEC: "desec", DIGITALOCEAN: "digitalocean", DINGTALKBOT: "dingtalkbot", @@ -144,6 +145,7 @@ export const accessProvidersMap: Maphttps://ecloud.10086.cn/op-help-center/doc/article/49739", + "access.form.constellix_api_key.label": "Constellix API key", + "access.form.constellix_api_key.placeholder": "Please enter Constellix API key", + "access.form.constellix_api_key.tooltip": "For more information, see https://support.constellix.com/hc/en-us/articles/34574197390491-How-to-Generate-an-API-Key", + "access.form.constellix_secret_key.label": "Constellix API secret key", + "access.form.constellix_secret_key.placeholder": "Please enter Constellix API secret key", + "access.form.constellix_secret_key.tooltip": "For more information, see https://support.constellix.com/hc/en-us/articles/34574197390491-How-to-Generate-an-API-Key", "access.form.desec_token.label": "deSEC token", "access.form.desec_token.placeholder": "Please enter deSEC token", "access.form.desec_token.tooltip": "For more information, see https://desec.readthedocs.io/en/latest/auth/tokens.html", diff --git a/ui/src/i18n/locales/en/nls.provider.json b/ui/src/i18n/locales/en/nls.provider.json index 9e59d9d0..72f5e487 100644 --- a/ui/src/i18n/locales/en/nls.provider.json +++ b/ui/src/i18n/locales/en/nls.provider.json @@ -55,6 +55,7 @@ "provider.cloudflare": "Cloudflare", "provider.cloudns": "ClouDNS", "provider.cmcccloud": "China Mobile Cloud (ECloud)", + "provider.constellix": "Constellix", "provider.ctcccloud": "China Telecom Cloud (State Cloud)", "provider.cucccloud": "China Unicom Cloud", "provider.desec": "deSEC", diff --git a/ui/src/i18n/locales/zh/nls.access.json b/ui/src/i18n/locales/zh/nls.access.json index 66563f32..8af85792 100644 --- a/ui/src/i18n/locales/zh/nls.access.json +++ b/ui/src/i18n/locales/zh/nls.access.json @@ -146,6 +146,12 @@ "access.form.cmcccloud_access_key_secret.label": "移动云 AccessKeySecret", "access.form.cmcccloud_access_key_secret.placeholder": "请输入移动云 AccessKeySecret", "access.form.cmcccloud_access_key_secret.tooltip": "这是什么?请参阅 https://ecloud.10086.cn/op-help-center/doc/article/49739", + "access.form.constellix_api_key.label": "Constellix API Key", + "access.form.constellix_api_key.placeholder": "请输入 Constellix API Key", + "access.form.constellix_api_key.tooltip": "这是什么?请参阅 https://support.constellix.com/hc/en-us/articles/34574197390491-How-to-Generate-an-API-Key", + "access.form.constellix_secret_key.label": "Constellix Secret Key", + "access.form.constellix_secret_key.placeholder": "请输入 Constellix Secret Key", + "access.form.constellix_secret_key.tooltip": "这是什么?请参阅 https://support.constellix.com/hc/en-us/articles/34574197390491-How-to-Generate-an-API-Key", "access.form.desec_token.label": "deSEC Token", "access.form.desec_token.placeholder": "请输入 deSEC Token", "access.form.desec_token.tooltip": "这是什么?请参阅 https://desec.readthedocs.io/en/latest/auth/tokens.html", diff --git a/ui/src/i18n/locales/zh/nls.provider.json b/ui/src/i18n/locales/zh/nls.provider.json index 27aa8d0e..79bba686 100644 --- a/ui/src/i18n/locales/zh/nls.provider.json +++ b/ui/src/i18n/locales/zh/nls.provider.json @@ -55,6 +55,7 @@ "provider.cloudflare": "Cloudflare", "provider.cloudns": "ClouDNS", "provider.cmcccloud": "移动云", + "provider.constellix": "Constellix", "provider.ctcccloud": "联通云", "provider.cucccloud": "天翼云", "provider.desec": "deSEC", From 6dc65eea2f89b8c45bb081752f68980c3a11b199 Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Tue, 3 Jun 2025 16:51:33 +0800 Subject: [PATCH 2/5] feat: new acme dns-01 provider: ucloud udnr --- internal/applicant/providers.go | 17 ++ internal/domain/provider.go | 1 + .../aliyun-esa/internal/lego.go | 11 +- .../baiducloud/internal/lego.go | 2 +- .../lego-providers/dnsla/internal/lego.go | 2 +- .../lego-providers/dynv6/internal/lego.go | 2 +- .../lego-providers/gname/internal/lego.go | 2 +- .../lego-providers/jdcloud/internal/lego.go | 2 +- .../tencentcloud-eo/internal/lego.go | 7 +- .../ucloud-udnr/internal/lego.go | 165 ++++++++++++++++++ .../lego-providers/ucloud-udnr/ucloud_udnr.go | 40 +++++ internal/pkg/sdk3rd/ucloud/udnr/apis.go | 115 ++++++++++++ internal/pkg/sdk3rd/ucloud/udnr/client.go | 18 ++ internal/pkg/sdk3rd/ucloud/udnr/models.go | 9 + ui/src/domain/provider.ts | 4 +- ui/src/i18n/locales/en/nls.provider.json | 9 +- ui/src/i18n/locales/zh/nls.provider.json | 1 + 17 files changed, 387 insertions(+), 20 deletions(-) create mode 100644 internal/pkg/core/applicant/acme-dns-01/lego-providers/ucloud-udnr/internal/lego.go create mode 100644 internal/pkg/core/applicant/acme-dns-01/lego-providers/ucloud-udnr/ucloud_udnr.go create mode 100644 internal/pkg/sdk3rd/ucloud/udnr/apis.go create mode 100644 internal/pkg/sdk3rd/ucloud/udnr/client.go create mode 100644 internal/pkg/sdk3rd/ucloud/udnr/models.go diff --git a/internal/applicant/providers.go b/internal/applicant/providers.go index ba4fadef..fbf24742 100644 --- a/internal/applicant/providers.go +++ b/internal/applicant/providers.go @@ -39,6 +39,7 @@ import ( pRainYun "github.com/usual2970/certimate/internal/pkg/core/applicant/acme-dns-01/lego-providers/rainyun" pTencentCloud "github.com/usual2970/certimate/internal/pkg/core/applicant/acme-dns-01/lego-providers/tencentcloud" pTencentCloudEO "github.com/usual2970/certimate/internal/pkg/core/applicant/acme-dns-01/lego-providers/tencentcloud-eo" + pUCloudUDNR "github.com/usual2970/certimate/internal/pkg/core/applicant/acme-dns-01/lego-providers/ucloud-udnr" pVercel "github.com/usual2970/certimate/internal/pkg/core/applicant/acme-dns-01/lego-providers/vercel" pVolcEngine "github.com/usual2970/certimate/internal/pkg/core/applicant/acme-dns-01/lego-providers/volcengine" pWestcn "github.com/usual2970/certimate/internal/pkg/core/applicant/acme-dns-01/lego-providers/westcn" @@ -596,6 +597,22 @@ func createApplicantProvider(options *applicantProviderOptions) (challenge.Provi } } + case domain.ACMEDns01ProviderTypeUCloudUDNR: + { + access := domain.AccessConfigForUCloud{} + if err := maputil.Populate(options.ProviderAccessConfig, &access); err != nil { + return nil, fmt.Errorf("failed to populate provider access config: %w", err) + } + + applicant, err := pUCloudUDNR.NewChallengeProvider(&pUCloudUDNR.ChallengeProviderConfig{ + PrivateKey: access.PrivateKey, + PublicKey: access.PublicKey, + DnsPropagationTimeout: options.DnsPropagationTimeout, + DnsTTL: options.DnsTTL, + }) + return applicant, err + } + case domain.ACMEDns01ProviderTypeVercel: { access := domain.AccessConfigForVercel{} diff --git a/internal/domain/provider.go b/internal/domain/provider.go index dd9663f2..1a30bdad 100644 --- a/internal/domain/provider.go +++ b/internal/domain/provider.go @@ -158,6 +158,7 @@ const ( ACMEDns01ProviderTypeTencentCloud = ACMEDns01ProviderType(AccessProviderTypeTencentCloud) // 兼容旧值,等同于 [ACMEDns01ProviderTypeTencentCloudDNS] ACMEDns01ProviderTypeTencentCloudDNS = ACMEDns01ProviderType(AccessProviderTypeTencentCloud + "-dns") ACMEDns01ProviderTypeTencentCloudEO = ACMEDns01ProviderType(AccessProviderTypeTencentCloud + "-eo") + ACMEDns01ProviderTypeUCloudUDNR = ACMEDns01ProviderType(AccessProviderTypeUCloud + "-udnr") ACMEDns01ProviderTypeVercel = ACMEDns01ProviderType(AccessProviderTypeVercel) ACMEDns01ProviderTypeVolcEngine = ACMEDns01ProviderType(AccessProviderTypeVolcEngine) // 兼容旧值,等同于 [ACMEDns01ProviderTypeVolcEngineDNS] ACMEDns01ProviderTypeVolcEngineDNS = ACMEDns01ProviderType(AccessProviderTypeVolcEngine + "-dns") diff --git a/internal/pkg/core/applicant/acme-dns-01/lego-providers/aliyun-esa/internal/lego.go b/internal/pkg/core/applicant/acme-dns-01/lego-providers/aliyun-esa/internal/lego.go index 5a576af1..43c488f5 100644 --- a/internal/pkg/core/applicant/acme-dns-01/lego-providers/aliyun-esa/internal/lego.go +++ b/internal/pkg/core/applicant/acme-dns-01/lego-providers/aliyun-esa/internal/lego.go @@ -1,9 +1,8 @@ -package lego_aliyunesa +package internal import ( "errors" "fmt" - "strings" "sync" "time" @@ -102,13 +101,13 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { return fmt.Errorf("alicloud-esa: could not find zone for domain %q: %w", domain, err) } - siteName := strings.TrimRight(authZone, ".") + siteName := dns01.UnFqdn(authZone) siteId, err := d.getSiteId(siteName) if err != nil { return fmt.Errorf("alicloud-esa: could not find site for zone %q: %w", siteName, err) } - if err := d.addOrUpdateDNSRecord(siteId, strings.TrimRight(info.EffectiveFQDN, "."), info.Value); err != nil { + if err := d.addOrUpdateDNSRecord(siteId, dns01.UnFqdn(info.EffectiveFQDN), info.Value); err != nil { return fmt.Errorf("alicloud-esa: %w", err) } @@ -123,13 +122,13 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { return fmt.Errorf("alicloud-esa: could not find zone for domain %q: %w", domain, err) } - siteName := strings.TrimRight(authZone, ".") + siteName := dns01.UnFqdn(authZone) siteId, err := d.getSiteId(siteName) if err != nil { return fmt.Errorf("alicloud-esa: could not find site for zone %q: %w", siteName, err) } - if err := d.removeDNSRecord(siteId, strings.TrimRight(info.EffectiveFQDN, ".")); err != nil { + if err := d.removeDNSRecord(siteId, dns01.UnFqdn(info.EffectiveFQDN)); err != nil { return fmt.Errorf("alicloud-esa: %w", err) } diff --git a/internal/pkg/core/applicant/acme-dns-01/lego-providers/baiducloud/internal/lego.go b/internal/pkg/core/applicant/acme-dns-01/lego-providers/baiducloud/internal/lego.go index f67662b5..4c66f088 100644 --- a/internal/pkg/core/applicant/acme-dns-01/lego-providers/baiducloud/internal/lego.go +++ b/internal/pkg/core/applicant/acme-dns-01/lego-providers/baiducloud/internal/lego.go @@ -1,4 +1,4 @@ -package lego_baiducloud +package internal import ( "errors" diff --git a/internal/pkg/core/applicant/acme-dns-01/lego-providers/dnsla/internal/lego.go b/internal/pkg/core/applicant/acme-dns-01/lego-providers/dnsla/internal/lego.go index 87cb6cd9..1063ac5f 100644 --- a/internal/pkg/core/applicant/acme-dns-01/lego-providers/dnsla/internal/lego.go +++ b/internal/pkg/core/applicant/acme-dns-01/lego-providers/dnsla/internal/lego.go @@ -1,4 +1,4 @@ -package lego_dnsla +package internal import ( "errors" diff --git a/internal/pkg/core/applicant/acme-dns-01/lego-providers/dynv6/internal/lego.go b/internal/pkg/core/applicant/acme-dns-01/lego-providers/dynv6/internal/lego.go index 8b33cf9e..36a06ffa 100644 --- a/internal/pkg/core/applicant/acme-dns-01/lego-providers/dynv6/internal/lego.go +++ b/internal/pkg/core/applicant/acme-dns-01/lego-providers/dynv6/internal/lego.go @@ -1,4 +1,4 @@ -package lego_dynv6 +package internal import ( "context" diff --git a/internal/pkg/core/applicant/acme-dns-01/lego-providers/gname/internal/lego.go b/internal/pkg/core/applicant/acme-dns-01/lego-providers/gname/internal/lego.go index 7f1f5670..6bfda830 100644 --- a/internal/pkg/core/applicant/acme-dns-01/lego-providers/gname/internal/lego.go +++ b/internal/pkg/core/applicant/acme-dns-01/lego-providers/gname/internal/lego.go @@ -1,4 +1,4 @@ -package lego_gname +package internal import ( "errors" diff --git a/internal/pkg/core/applicant/acme-dns-01/lego-providers/jdcloud/internal/lego.go b/internal/pkg/core/applicant/acme-dns-01/lego-providers/jdcloud/internal/lego.go index a1851a11..0361c7cb 100644 --- a/internal/pkg/core/applicant/acme-dns-01/lego-providers/jdcloud/internal/lego.go +++ b/internal/pkg/core/applicant/acme-dns-01/lego-providers/jdcloud/internal/lego.go @@ -1,4 +1,4 @@ -package lego_jdcloud +package internal import ( "errors" diff --git a/internal/pkg/core/applicant/acme-dns-01/lego-providers/tencentcloud-eo/internal/lego.go b/internal/pkg/core/applicant/acme-dns-01/lego-providers/tencentcloud-eo/internal/lego.go index 692c42d3..69ad8a80 100644 --- a/internal/pkg/core/applicant/acme-dns-01/lego-providers/tencentcloud-eo/internal/lego.go +++ b/internal/pkg/core/applicant/acme-dns-01/lego-providers/tencentcloud-eo/internal/lego.go @@ -1,10 +1,9 @@ -package lego_tencentcloudeo +package internal import ( "errors" "fmt" "math" - "strings" "time" "github.com/go-acme/lego/v4/challenge" @@ -91,7 +90,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) - if err := d.addOrUpdateDNSRecord(strings.TrimRight(info.EffectiveFQDN, "."), info.Value); err != nil { + if err := d.addOrUpdateDNSRecord(dns01.UnFqdn(info.EffectiveFQDN), info.Value); err != nil { return fmt.Errorf("tencentcloud-eo: %w", err) } @@ -101,7 +100,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) - if err := d.removeDNSRecord(strings.TrimRight(info.EffectiveFQDN, ".")); err != nil { + if err := d.removeDNSRecord(dns01.UnFqdn(info.EffectiveFQDN)); err != nil { return fmt.Errorf("tencentcloud-eo: %w", err) } diff --git a/internal/pkg/core/applicant/acme-dns-01/lego-providers/ucloud-udnr/internal/lego.go b/internal/pkg/core/applicant/acme-dns-01/lego-providers/ucloud-udnr/internal/lego.go new file mode 100644 index 00000000..e1be56a4 --- /dev/null +++ b/internal/pkg/core/applicant/acme-dns-01/lego-providers/ucloud-udnr/internal/lego.go @@ -0,0 +1,165 @@ +package internal + +import ( + "errors" + "fmt" + "time" + + "github.com/go-acme/lego/v4/challenge" + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/ucloud/ucloud-sdk-go/ucloud" + "github.com/ucloud/ucloud-sdk-go/ucloud/auth" + + "github.com/usual2970/certimate/internal/pkg/sdk3rd/ucloud/udnr" +) + +const ( + envNamespace = "UCLOUDUDNR_" + + EnvPublicKey = envNamespace + "PUBLIC_KEY" + EnvPrivateKey = envNamespace + "PRIVATE_KEY" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + +type Config struct { + PrivateKey string + PublicKey string + + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int32 + HTTPTimeout time.Duration +} + +type DNSProvider struct { + client *udnr.UDNRClient + config *Config +} + +func NewDefaultConfig() *Config { + return &Config{ + TTL: int32(env.GetOrDefaultInt(EnvTTL, 300)), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + HTTPTimeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + } +} + +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvPrivateKey, EnvPublicKey) + if err != nil { + return nil, fmt.Errorf("ucloud-udnr: %w", err) + } + + config := NewDefaultConfig() + config.PrivateKey = values[EnvPrivateKey] + config.PublicKey = values[EnvPublicKey] + + return NewDNSProviderConfig(config) +} + +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("ucloud-udnr: the configuration of the DNS provider is nil") + } + + cfg := ucloud.NewConfig() + credential := auth.NewCredential() + credential.PrivateKey = config.PrivateKey + credential.PublicKey = config.PublicKey + client := udnr.NewClient(&cfg, &credential) + + return &DNSProvider{ + client: client, + config: config, + }, nil +} + +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("ucloud-udnr: could not find zone for domain %q: %w", domain, err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) + if err != nil { + return fmt.Errorf("ucloud-udnr: %w", err) + } + + udnrDomainDNSQueryReq := d.client.NewQueryDomainDNSRequest() + udnrDomainDNSQueryReq.Dn = ucloud.String(authZone) + if udnrDomainDNSQueryResp, err := d.client.QueryDomainDNS(udnrDomainDNSQueryReq); err != nil { + return fmt.Errorf("ucloud-udnr: %w", err) + } else { + for _, record := range udnrDomainDNSQueryResp.Data { + if record.DnsType == "TXT" && record.RecordName == subDomain { + udnrDomainDNSDeleteReq := d.client.NewDeleteDomainDNSRequest() + udnrDomainDNSDeleteReq.Dn = ucloud.String(authZone) + udnrDomainDNSDeleteReq.DnsType = ucloud.String(record.DnsType) + udnrDomainDNSDeleteReq.RecordName = ucloud.String(record.RecordName) + udnrDomainDNSDeleteReq.Content = ucloud.String(record.Content) + d.client.DeleteDomainDNS(udnrDomainDNSDeleteReq) + break + } + } + } + + udnrDomainDNSAddReq := d.client.NewAddDomainDNSRequest() + udnrDomainDNSAddReq.Dn = ucloud.String(authZone) + udnrDomainDNSAddReq.DnsType = ucloud.String("TXT") + udnrDomainDNSAddReq.RecordName = ucloud.String(subDomain) + udnrDomainDNSAddReq.Content = ucloud.String(info.Value) + udnrDomainDNSAddReq.TTL = ucloud.Int(int(d.config.TTL)) + if _, err := d.client.AddDomainDNS(udnrDomainDNSAddReq); err != nil { + return fmt.Errorf("ucloud-udnr: %w", err) + } + + return nil +} + +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("ucloud-udnr: could not find zone for domain %q: %w", domain, err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) + if err != nil { + return fmt.Errorf("ucloud-udnr: %w", err) + } + + udnrDomainDNSQueryReq := d.client.NewQueryDomainDNSRequest() + udnrDomainDNSQueryReq.Dn = ucloud.String(authZone) + if udnrDomainDNSQueryResp, err := d.client.QueryDomainDNS(udnrDomainDNSQueryReq); err != nil { + return fmt.Errorf("ucloud-udnr: %w", err) + } else { + for _, record := range udnrDomainDNSQueryResp.Data { + if record.DnsType == "TXT" && record.RecordName == subDomain { + udnrDomainDNSDeleteReq := d.client.NewDeleteDomainDNSRequest() + udnrDomainDNSDeleteReq.Dn = ucloud.String(authZone) + udnrDomainDNSDeleteReq.DnsType = ucloud.String(record.DnsType) + udnrDomainDNSDeleteReq.RecordName = ucloud.String(record.RecordName) + udnrDomainDNSDeleteReq.Content = ucloud.String(record.Content) + d.client.DeleteDomainDNS(udnrDomainDNSDeleteReq) + break + } + } + } + + return nil +} + +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} diff --git a/internal/pkg/core/applicant/acme-dns-01/lego-providers/ucloud-udnr/ucloud_udnr.go b/internal/pkg/core/applicant/acme-dns-01/lego-providers/ucloud-udnr/ucloud_udnr.go new file mode 100644 index 00000000..d1902747 --- /dev/null +++ b/internal/pkg/core/applicant/acme-dns-01/lego-providers/ucloud-udnr/ucloud_udnr.go @@ -0,0 +1,40 @@ +package ucloududnr + +import ( + "errors" + "time" + + "github.com/go-acme/lego/v4/challenge" + + "github.com/usual2970/certimate/internal/pkg/core/applicant/acme-dns-01/lego-providers/ucloud-udnr/internal" +) + +type ChallengeProviderConfig struct { + PrivateKey string `json:"privateKey"` + PublicKey string `json:"publicKey"` + DnsPropagationTimeout int32 `json:"dnsPropagationTimeout,omitempty"` + DnsTTL int32 `json:"dnsTTL,omitempty"` +} + +func NewChallengeProvider(config *ChallengeProviderConfig) (challenge.Provider, error) { + if config == nil { + return nil, errors.New("config is nil") + } + + providerConfig := internal.NewDefaultConfig() + providerConfig.PrivateKey = config.PrivateKey + providerConfig.PublicKey = config.PublicKey + if config.DnsTTL != 0 { + providerConfig.TTL = config.DnsTTL + } + if config.DnsPropagationTimeout != 0 { + providerConfig.PropagationTimeout = time.Duration(config.DnsPropagationTimeout) * time.Second + } + + provider, err := internal.NewDNSProviderConfig(providerConfig) + if err != nil { + return nil, err + } + + return provider, nil +} diff --git a/internal/pkg/sdk3rd/ucloud/udnr/apis.go b/internal/pkg/sdk3rd/ucloud/udnr/apis.go new file mode 100644 index 00000000..af878e5b --- /dev/null +++ b/internal/pkg/sdk3rd/ucloud/udnr/apis.go @@ -0,0 +1,115 @@ +package udnr + +import ( + "github.com/ucloud/ucloud-sdk-go/ucloud/request" + "github.com/ucloud/ucloud-sdk-go/ucloud/response" +) + +type QueryDomainDNSRequest struct { + request.CommonBase + + Dn *string `required:"true"` +} + +type QueryDomainDNSResponse struct { + response.CommonBase + + Data []DomainDNSRecord +} + +func (c *UDNRClient) NewQueryDomainDNSRequest() *QueryDomainDNSRequest { + req := &QueryDomainDNSRequest{} + + c.Client.SetupRequest(req) + + req.SetRetryable(false) + return req +} + +func (c *UDNRClient) QueryDomainDNS(req *QueryDomainDNSRequest) (*QueryDomainDNSResponse, error) { + var err error + var res QueryDomainDNSResponse + + reqCopier := *req + + err = c.Client.InvokeAction("UdnrDomainDNSQuery", &reqCopier, &res) + if err != nil { + return &res, err + } + + return &res, nil +} + +type AddDomainDNSRequest struct { + request.CommonBase + + Dn *string `required:"true"` + DnsType *string `required:"true"` + RecordName *string `required:"true"` + Content *string `required:"true"` + TTL *int `required:"true"` + Prio *int `required:"false"` +} + +type AddDomainDNSResponse struct { + response.CommonBase +} + +func (c *UDNRClient) NewAddDomainDNSRequest() *AddDomainDNSRequest { + req := &AddDomainDNSRequest{} + + c.Client.SetupRequest(req) + + req.SetRetryable(false) + return req +} + +func (c *UDNRClient) AddDomainDNS(req *AddDomainDNSRequest) (*AddDomainDNSResponse, error) { + var err error + var res AddDomainDNSResponse + + reqCopier := *req + + err = c.Client.InvokeAction("UdnrDomainDNSAdd", &reqCopier, &res) + if err != nil { + return &res, err + } + + return &res, nil +} + +type DeleteDomainDNSRequest struct { + request.CommonBase + + Dn *string `required:"true"` + DnsType *string `required:"true"` + RecordName *string `required:"true"` + Content *string `required:"true"` +} + +type DeleteDomainDNSResponse struct { + response.CommonBase +} + +func (c *UDNRClient) NewDeleteDomainDNSRequest() *DeleteDomainDNSRequest { + req := &DeleteDomainDNSRequest{} + + c.Client.SetupRequest(req) + + req.SetRetryable(false) + return req +} + +func (c *UDNRClient) DeleteDomainDNS(req *DeleteDomainDNSRequest) (*DeleteDomainDNSResponse, error) { + var err error + var res DeleteDomainDNSResponse + + reqCopier := *req + + err = c.Client.InvokeAction("UdnrDeleteDnsRecord", &reqCopier, &res) + if err != nil { + return &res, err + } + + return &res, nil +} diff --git a/internal/pkg/sdk3rd/ucloud/udnr/client.go b/internal/pkg/sdk3rd/ucloud/udnr/client.go new file mode 100644 index 00000000..5e23f227 --- /dev/null +++ b/internal/pkg/sdk3rd/ucloud/udnr/client.go @@ -0,0 +1,18 @@ +package udnr + +import ( + "github.com/ucloud/ucloud-sdk-go/ucloud" + "github.com/ucloud/ucloud-sdk-go/ucloud/auth" +) + +type UDNRClient struct { + *ucloud.Client +} + +func NewClient(config *ucloud.Config, credential *auth.Credential) *UDNRClient { + meta := ucloud.ClientMeta{Product: "UDNR"} + client := ucloud.NewClientWithMeta(config, credential, meta) + return &UDNRClient{ + client, + } +} diff --git a/internal/pkg/sdk3rd/ucloud/udnr/models.go b/internal/pkg/sdk3rd/ucloud/udnr/models.go new file mode 100644 index 00000000..4d2081f5 --- /dev/null +++ b/internal/pkg/sdk3rd/ucloud/udnr/models.go @@ -0,0 +1,9 @@ +package udnr + +type DomainDNSRecord struct { + DnsType string + RecordName string + Content string + TTL int + Prio int +} diff --git a/ui/src/domain/provider.ts b/ui/src/domain/provider.ts index 5bd1439b..64a52723 100644 --- a/ui/src/domain/provider.ts +++ b/ui/src/domain/provider.ts @@ -121,6 +121,7 @@ export const accessProvidersMap: Map Date: Tue, 3 Jun 2025 22:22:54 +0800 Subject: [PATCH 3/5] feat: new deployment provider: aws iam --- go.mod | 1 + go.sum | 2 + internal/deployer/providers.go | 15 +- internal/domain/provider.go | 1 + .../aws-cloudfront/aws_cloudfront.go | 51 ++++- .../deployer/providers/aws-iam/aws_iam.go | 75 +++++++ .../uploader/providers/aws-acm/aws_acm.go | 12 +- .../uploader/providers/aws-iam/aws_iam.go | 185 ++++++++++++++++++ .../workflow/node/DeployNodeConfigForm.tsx | 3 + ...eployNodeConfigFormAWSCloudFrontConfig.tsx | 30 +-- .../node/DeployNodeConfigFormAWSIAMConfig.tsx | 77 ++++++++ ui/src/domain/provider.ts | 2 + ui/src/i18n/locales/en/nls.provider.json | 1 + .../i18n/locales/en/nls.workflow.nodes.json | 9 + ui/src/i18n/locales/zh/nls.provider.json | 1 + .../i18n/locales/zh/nls.workflow.nodes.json | 9 + 16 files changed, 443 insertions(+), 31 deletions(-) create mode 100644 internal/pkg/core/deployer/providers/aws-iam/aws_iam.go create mode 100644 internal/pkg/core/uploader/providers/aws-iam/aws_iam.go create mode 100644 ui/src/components/workflow/node/DeployNodeConfigFormAWSIAMConfig.tsx diff --git a/go.mod b/go.mod index 0f12bd45..bca64be5 100644 --- a/go.mod +++ b/go.mod @@ -85,6 +85,7 @@ require ( github.com/alibabacloud-go/tea-oss-utils v1.1.0 // indirect github.com/alibabacloud-go/tea-utils/v2 v2.0.7 // indirect github.com/avast/retry-go v3.0.0+incompatible // indirect + github.com/aws/aws-sdk-go-v2/service/iam v1.42.0 // indirect github.com/aws/aws-sdk-go-v2/service/route53 v1.50.0 // indirect github.com/buger/goterm v1.0.4 // indirect github.com/diskfs/go-diskfs v1.5.0 // indirect diff --git a/go.sum b/go.sum index 7bbf5848..404e21e4 100644 --- a/go.sum +++ b/go.sum @@ -235,6 +235,8 @@ github.com/aws/aws-sdk-go-v2/service/acm v1.32.0/go.mod h1:3sKYAgRbuBa2QMYGh/WEc github.com/aws/aws-sdk-go-v2/service/cloudfront v1.46.1 h1:6xZNYtuVwzBs8k+TmraERt0vL68Ppg9aUi+aTQmPaVM= github.com/aws/aws-sdk-go-v2/service/cloudfront v1.46.1/go.mod h1:FIBJ48TS+qJb+Ne4qJ+0NeIhtPTVXItXooTeNeVI4Po= github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.8.1/go.mod h1:CM+19rL1+4dFWnOQKwDc7H1KwXTz+h61oUSHyhV0b3o= +github.com/aws/aws-sdk-go-v2/service/iam v1.42.0 h1:G6+UzGvubaet9QOh0664E9JeT+b6Zvop3AChozRqkrA= +github.com/aws/aws-sdk-go-v2/service/iam v1.42.0/go.mod h1:mPJkGQzeCoPs82ElNILor2JzZgYENr4UaSKUT8K27+c= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM= diff --git a/internal/deployer/providers.go b/internal/deployer/providers.go index e67c29e0..6f02c97a 100644 --- a/internal/deployer/providers.go +++ b/internal/deployer/providers.go @@ -27,6 +27,7 @@ import ( pAliyunWAF "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/aliyun-waf" pAWSACM "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/aws-acm" pAWSCloudFront "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/aws-cloudfront" + pAWSIAM "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/aws-iam" pAzureKeyVault "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/azure-keyvault" pBaiduCloudAppBLB "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/baiducloud-appblb" pBaiduCloudBLB "github.com/usual2970/certimate/internal/pkg/core/deployer/providers/baiducloud-blb" @@ -331,7 +332,7 @@ func createDeployerProvider(options *deployerProviderOptions) (deployer.Deployer } } - case domain.DeploymentProviderTypeAWSACM, domain.DeploymentProviderTypeAWSCloudFront: + case domain.DeploymentProviderTypeAWSACM, domain.DeploymentProviderTypeAWSCloudFront, domain.DeploymentProviderTypeAWSIAM: { access := domain.AccessConfigForAWS{} if err := maputil.Populate(options.ProviderAccessConfig, &access); err != nil { @@ -350,10 +351,20 @@ func createDeployerProvider(options *deployerProviderOptions) (deployer.Deployer case domain.DeploymentProviderTypeAWSCloudFront: deployer, err := pAWSCloudFront.NewDeployer(&pAWSCloudFront.DeployerConfig{ + AccessKeyId: access.AccessKeyId, + SecretAccessKey: access.SecretAccessKey, + Region: maputil.GetString(options.ProviderServiceConfig, "region"), + DistributionId: maputil.GetString(options.ProviderServiceConfig, "distributionId"), + CertificateSource: maputil.GetOrDefaultString(options.ProviderServiceConfig, "certificateSource", "ACM"), + }) + return deployer, err + + case domain.DeploymentProviderTypeAWSIAM: + deployer, err := pAWSIAM.NewDeployer(&pAWSIAM.DeployerConfig{ AccessKeyId: access.AccessKeyId, SecretAccessKey: access.SecretAccessKey, Region: maputil.GetString(options.ProviderServiceConfig, "region"), - DistributionId: maputil.GetString(options.ProviderServiceConfig, "distributionId"), + CertificatePath: maputil.GetOrDefaultString(options.ProviderServiceConfig, "certificatePath", "/"), }) return deployer, err diff --git a/internal/domain/provider.go b/internal/domain/provider.go index 1a30bdad..560b08da 100644 --- a/internal/domain/provider.go +++ b/internal/domain/provider.go @@ -195,6 +195,7 @@ const ( DeploymentProviderTypeAliyunWAF = DeploymentProviderType(AccessProviderTypeAliyun + "-waf") DeploymentProviderTypeAWSACM = DeploymentProviderType(AccessProviderTypeAWS + "-acm") DeploymentProviderTypeAWSCloudFront = DeploymentProviderType(AccessProviderTypeAWS + "-cloudfront") + DeploymentProviderTypeAWSIAM = DeploymentProviderType(AccessProviderTypeAWS + "-iam") DeploymentProviderTypeAzureKeyVault = DeploymentProviderType(AccessProviderTypeAzure + "-keyvault") DeploymentProviderTypeBaiduCloudAppBLB = DeploymentProviderType(AccessProviderTypeBaiduCloud + "-appblb") DeploymentProviderTypeBaiduCloudBLB = DeploymentProviderType(AccessProviderTypeBaiduCloud + "-blb") diff --git a/internal/pkg/core/deployer/providers/aws-cloudfront/aws_cloudfront.go b/internal/pkg/core/deployer/providers/aws-cloudfront/aws_cloudfront.go index 7ec17044..e5a3f0b2 100644 --- a/internal/pkg/core/deployer/providers/aws-cloudfront/aws_cloudfront.go +++ b/internal/pkg/core/deployer/providers/aws-cloudfront/aws_cloudfront.go @@ -14,7 +14,8 @@ import ( "github.com/usual2970/certimate/internal/pkg/core/deployer" "github.com/usual2970/certimate/internal/pkg/core/uploader" - uploadersp "github.com/usual2970/certimate/internal/pkg/core/uploader/providers/aws-acm" + uploaderspacm "github.com/usual2970/certimate/internal/pkg/core/uploader/providers/aws-acm" + uploaderspiam "github.com/usual2970/certimate/internal/pkg/core/uploader/providers/aws-iam" ) type DeployerConfig struct { @@ -26,6 +27,9 @@ type DeployerConfig struct { Region string `json:"region"` // AWS CloudFront 分配 ID。 DistributionId string `json:"distributionId"` + // AWS CloudFront 证书来源。 + // 可取值 "ACM"、"IAM"。 + CertificateSource string `json:"certificateSource"` } type DeployerProvider struct { @@ -47,13 +51,28 @@ func NewDeployer(config *DeployerConfig) (*DeployerProvider, error) { return nil, fmt.Errorf("failed to create sdk client: %w", err) } - uploader, err := uploadersp.NewUploader(&uploadersp.UploaderConfig{ - AccessKeyId: config.AccessKeyId, - SecretAccessKey: config.SecretAccessKey, - Region: config.Region, - }) - if err != nil { - return nil, fmt.Errorf("failed to create ssl uploader: %w", err) + var uploader uploader.Uploader + if config.CertificateSource == "ACM" { + uploader, err = uploaderspacm.NewUploader(&uploaderspacm.UploaderConfig{ + AccessKeyId: config.AccessKeyId, + SecretAccessKey: config.SecretAccessKey, + Region: config.Region, + }) + if err != nil { + return nil, fmt.Errorf("failed to create ssl uploader: %w", err) + } + } else if config.CertificateSource == "IAM" { + uploader, err = uploaderspiam.NewUploader(&uploaderspiam.UploaderConfig{ + AccessKeyId: config.AccessKeyId, + SecretAccessKey: config.SecretAccessKey, + Region: config.Region, + CertificatePath: "/cloudfront/", + }) + if err != nil { + return nil, fmt.Errorf("failed to create ssl uploader: %w", err) + } + } else { + return nil, fmt.Errorf("unsupported certificate source: '%s'", config.CertificateSource) } return &DeployerProvider{ @@ -79,7 +98,7 @@ func (d *DeployerProvider) Deploy(ctx context.Context, certPEM string, privkeyPE return nil, errors.New("config `distribuitionId` is required") } - // 上传证书到 ACM + // 上传证书到 ACM/IAM upres, err := d.sslUploader.Upload(ctx, certPEM, privkeyPEM) if err != nil { return nil, fmt.Errorf("failed to upload certificate file: %w", err) @@ -109,7 +128,19 @@ func (d *DeployerProvider) Deploy(ctx context.Context, certPEM string, privkeyPE updateDistributionReq.DistributionConfig.ViewerCertificate = &types.ViewerCertificate{} } updateDistributionReq.DistributionConfig.ViewerCertificate.CloudFrontDefaultCertificate = aws.Bool(false) - updateDistributionReq.DistributionConfig.ViewerCertificate.ACMCertificateArn = aws.String(upres.CertId) + if d.config.CertificateSource == "ACM" { + updateDistributionReq.DistributionConfig.ViewerCertificate.ACMCertificateArn = aws.String(upres.CertId) + updateDistributionReq.DistributionConfig.ViewerCertificate.IAMCertificateId = nil + } else if d.config.CertificateSource == "IAM" { + updateDistributionReq.DistributionConfig.ViewerCertificate.ACMCertificateArn = nil + updateDistributionReq.DistributionConfig.ViewerCertificate.IAMCertificateId = aws.String(upres.CertId) + if updateDistributionReq.DistributionConfig.ViewerCertificate.MinimumProtocolVersion == "" { + updateDistributionReq.DistributionConfig.ViewerCertificate.MinimumProtocolVersion = types.MinimumProtocolVersionTLSv1 + } + if updateDistributionReq.DistributionConfig.ViewerCertificate.SSLSupportMethod == "" { + updateDistributionReq.DistributionConfig.ViewerCertificate.SSLSupportMethod = types.SSLSupportMethodSniOnly + } + } updateDistributionResp, err := d.sdkClient.UpdateDistribution(context.TODO(), updateDistributionReq) d.logger.Debug("sdk request 'cloudfront.UpdateDistribution'", slog.Any("request", updateDistributionReq), slog.Any("response", updateDistributionResp)) if err != nil { diff --git a/internal/pkg/core/deployer/providers/aws-iam/aws_iam.go b/internal/pkg/core/deployer/providers/aws-iam/aws_iam.go new file mode 100644 index 00000000..ef6440d3 --- /dev/null +++ b/internal/pkg/core/deployer/providers/aws-iam/aws_iam.go @@ -0,0 +1,75 @@ +package awsiam + +import ( + "context" + "fmt" + "log/slog" + + "github.com/usual2970/certimate/internal/pkg/core/deployer" + "github.com/usual2970/certimate/internal/pkg/core/uploader" + uploadersp "github.com/usual2970/certimate/internal/pkg/core/uploader/providers/aws-iam" +) + +type DeployerConfig struct { + // AWS AccessKeyId。 + AccessKeyId string `json:"accessKeyId"` + // AWS SecretAccessKey。 + SecretAccessKey string `json:"secretAccessKey"` + // AWS 区域。 + Region string `json:"region"` + // IAM 证书路径。 + // 选填。 + CertificatePath string `json:"certificatePath,omitempty"` +} + +type DeployerProvider struct { + config *DeployerConfig + logger *slog.Logger + sslUploader uploader.Uploader +} + +var _ deployer.Deployer = (*DeployerProvider)(nil) + +func NewDeployer(config *DeployerConfig) (*DeployerProvider, error) { + if config == nil { + panic("config is nil") + } + + uploader, err := uploadersp.NewUploader(&uploadersp.UploaderConfig{ + AccessKeyId: config.AccessKeyId, + SecretAccessKey: config.SecretAccessKey, + Region: config.Region, + CertificatePath: config.CertificatePath, + }) + if err != nil { + return nil, fmt.Errorf("failed to create ssl uploader: %w", err) + } + + return &DeployerProvider{ + config: config, + logger: slog.Default(), + sslUploader: uploader, + }, nil +} + +func (d *DeployerProvider) WithLogger(logger *slog.Logger) deployer.Deployer { + if logger == nil { + d.logger = slog.New(slog.DiscardHandler) + } else { + d.logger = logger + } + d.sslUploader.WithLogger(logger) + return d +} + +func (d *DeployerProvider) Deploy(ctx context.Context, certPEM string, privkeyPEM string) (*deployer.DeployResult, error) { + // 上传证书到 IAM + upres, err := d.sslUploader.Upload(ctx, certPEM, privkeyPEM) + if err != nil { + return nil, fmt.Errorf("failed to upload certificate file: %w", err) + } else { + d.logger.Info("ssl certificate uploaded", slog.Any("result", upres)) + } + + return &deployer.DeployResult{}, nil +} diff --git a/internal/pkg/core/uploader/providers/aws-acm/aws_acm.go b/internal/pkg/core/uploader/providers/aws-acm/aws_acm.go index f68ebadc..4f215266 100644 --- a/internal/pkg/core/uploader/providers/aws-acm/aws_acm.go +++ b/internal/pkg/core/uploader/providers/aws-acm/aws_acm.go @@ -74,7 +74,7 @@ func (u *UploaderProvider) Upload(ctx context.Context, certPEM string, privkeyPE // 获取证书列表,避免重复上传 // REF: https://docs.aws.amazon.com/en_us/acm/latest/APIReference/API_ListCertificates.html var listCertificatesNextToken *string = nil - listCertificatesMaxItems := int32(1000) + var listCertificatesMaxItems int32 = 1000 for { select { case <-ctx.Done(): @@ -107,7 +107,7 @@ func (u *UploaderProvider) Upload(ctx context.Context, certPEM string, privkeyPE } // 最后对比证书内容 - // REF: https://docs.aws.amazon.com/en_us/acm/latest/APIReference/API_ListTagsForCertificate.html + // REF: https://docs.aws.amazon.com/en_us/acm/latest/APIReference/API_GetCertificate.html getCertificateReq := &awsacm.GetCertificateInput{ CertificateArn: certSummary.CertificateArn, } @@ -115,11 +115,7 @@ func (u *UploaderProvider) Upload(ctx context.Context, certPEM string, privkeyPE if err != nil { return nil, fmt.Errorf("failed to execute sdk request 'acm.GetCertificate': %w", err) } else { - oldCertPEM := aws.ToString(getCertificateResp.CertificateChain) - if oldCertPEM == "" { - oldCertPEM = aws.ToString(getCertificateResp.Certificate) - } - + oldCertPEM := aws.ToString(getCertificateResp.Certificate) oldCertX509, err := certutil.ParseCertificateFromPEM(oldCertPEM) if err != nil { continue @@ -158,7 +154,7 @@ func (u *UploaderProvider) Upload(ctx context.Context, certPEM string, privkeyPE } return &uploader.UploadResult{ - CertId: *importCertificateResp.CertificateArn, + CertId: aws.ToString(importCertificateResp.CertificateArn), }, nil } diff --git a/internal/pkg/core/uploader/providers/aws-iam/aws_iam.go b/internal/pkg/core/uploader/providers/aws-iam/aws_iam.go new file mode 100644 index 00000000..10f1a174 --- /dev/null +++ b/internal/pkg/core/uploader/providers/aws-iam/aws_iam.go @@ -0,0 +1,185 @@ +package awsiam + +import ( + "context" + "fmt" + "log/slog" + "time" + + aws "github.com/aws/aws-sdk-go-v2/aws" + awscfg "github.com/aws/aws-sdk-go-v2/config" + awscred "github.com/aws/aws-sdk-go-v2/credentials" + awsiam "github.com/aws/aws-sdk-go-v2/service/iam" + + "github.com/usual2970/certimate/internal/pkg/core/uploader" + certutil "github.com/usual2970/certimate/internal/pkg/utils/cert" +) + +type UploaderConfig struct { + // AWS AccessKeyId。 + AccessKeyId string `json:"accessKeyId"` + // AWS SecretAccessKey。 + SecretAccessKey string `json:"secretAccessKey"` + // AWS 区域。 + Region string `json:"region"` + // IAM 证书路径。 + // 选填。 + CertificatePath string `json:"certificatePath,omitempty"` +} + +type UploaderProvider struct { + config *UploaderConfig + logger *slog.Logger + sdkClient *awsiam.Client +} + +var _ uploader.Uploader = (*UploaderProvider)(nil) + +func NewUploader(config *UploaderConfig) (*UploaderProvider, error) { + if config == nil { + panic("config is nil") + } + + client, err := createSdkClient(config.AccessKeyId, config.SecretAccessKey, config.Region) + if err != nil { + return nil, fmt.Errorf("failed to create sdk client: %w", err) + } + + return &UploaderProvider{ + config: config, + logger: slog.Default(), + sdkClient: client, + }, nil +} + +func (u *UploaderProvider) WithLogger(logger *slog.Logger) uploader.Uploader { + if logger == nil { + u.logger = slog.New(slog.DiscardHandler) + } else { + u.logger = logger + } + return u +} + +func (u *UploaderProvider) Upload(ctx context.Context, certPEM string, privkeyPEM string) (*uploader.UploadResult, error) { + // 解析证书内容 + certX509, err := certutil.ParseCertificateFromPEM(certPEM) + if err != nil { + return nil, err + } + + // 提取服务器证书 + serverCertPEM, intermediaCertPEM, err := certutil.ExtractCertificatesFromPEM(certPEM) + if err != nil { + return nil, fmt.Errorf("failed to extract certs: %w", err) + } + + // 获取证书列表,避免重复上传 + // REF: https://docs.aws.amazon.com/en_us/IAM/latest/APIReference/API_ListServerCertificates.html + var listServerCertificatesMarker *string = nil + var listServerCertificatesMaxItems int32 = 1000 + for { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + listServerCertificatesReq := &awsiam.ListServerCertificatesInput{ + Marker: listServerCertificatesMarker, + MaxItems: aws.Int32(listServerCertificatesMaxItems), + } + if u.config.CertificatePath != "" { + listServerCertificatesReq.PathPrefix = aws.String(u.config.CertificatePath) + } + listServerCertificatesResp, err := u.sdkClient.ListServerCertificates(context.TODO(), listServerCertificatesReq) + u.logger.Debug("sdk request 'iam.ListServerCertificates'", slog.Any("request", listServerCertificatesReq), slog.Any("response", listServerCertificatesResp)) + if err != nil { + return nil, fmt.Errorf("failed to execute sdk request 'iam.ListServerCertificates': %w", err) + } + + for _, certMeta := range listServerCertificatesResp.ServerCertificateMetadataList { + // 先对比证书路径 + if u.config.CertificatePath != "" && aws.ToString(certMeta.Path) != u.config.CertificatePath { + continue + } + + // 先对比证书有效期 + if certMeta.Expiration == nil || !certMeta.Expiration.Equal(certX509.NotAfter) { + continue + } + + // 最后对比证书内容 + // REF: https://docs.aws.amazon.com/en_us/IAM/latest/APIReference/API_GetServerCertificate.html + getServerCertificateReq := &awsiam.GetServerCertificateInput{ + ServerCertificateName: certMeta.ServerCertificateName, + } + getServerCertificateResp, err := u.sdkClient.GetServerCertificate(context.TODO(), getServerCertificateReq) + if err != nil { + return nil, fmt.Errorf("failed to execute sdk request 'iam.GetServerCertificate': %w", err) + } else { + oldCertPEM := aws.ToString(getServerCertificateResp.ServerCertificate.CertificateBody) + oldCertX509, err := certutil.ParseCertificateFromPEM(oldCertPEM) + if err != nil { + continue + } + + if !certutil.EqualCertificate(certX509, oldCertX509) { + continue + } + } + + // 如果以上信息都一致,则视为已存在相同证书,直接返回 + u.logger.Info("ssl certificate already exists") + return &uploader.UploadResult{ + CertId: aws.ToString(certMeta.ServerCertificateId), + CertName: aws.ToString(certMeta.ServerCertificateName), + }, nil + } + + if listServerCertificatesResp.Marker == nil || len(listServerCertificatesResp.ServerCertificateMetadataList) < int(listServerCertificatesMaxItems) { + break + } else { + listServerCertificatesMarker = listServerCertificatesResp.Marker + } + } + + // 生成新证书名(需符合 AWS IAM 命名规则) + certName := fmt.Sprintf("certimate-%d", time.Now().UnixMilli()) + + // 导入证书 + // REF: https://docs.aws.amazon.com/en_us/IAM/latest/APIReference/API_UploadServerCertificate.html + uploadServerCertificateReq := &awsiam.UploadServerCertificateInput{ + ServerCertificateName: aws.String(certName), + Path: aws.String(u.config.CertificatePath), + CertificateBody: aws.String(serverCertPEM), + CertificateChain: aws.String(intermediaCertPEM), + PrivateKey: aws.String(privkeyPEM), + } + if u.config.CertificatePath == "" { + uploadServerCertificateReq.Path = aws.String("/") + } + uploadServerCertificateResp, err := u.sdkClient.UploadServerCertificate(context.TODO(), uploadServerCertificateReq) + u.logger.Debug("sdk request 'iam.UploadServerCertificate'", slog.Any("request", uploadServerCertificateReq), slog.Any("response", uploadServerCertificateResp)) + if err != nil { + return nil, fmt.Errorf("failed to execute sdk request 'iam.UploadServerCertificate': %w", err) + } + + return &uploader.UploadResult{ + CertId: aws.ToString(uploadServerCertificateResp.ServerCertificateMetadata.ServerCertificateId), + CertName: certName, + }, nil +} + +func createSdkClient(accessKeyId, secretAccessKey, region string) (*awsiam.Client, error) { + cfg, err := awscfg.LoadDefaultConfig(context.TODO()) + if err != nil { + return nil, err + } + + client := awsiam.NewFromConfig(cfg, func(o *awsiam.Options) { + o.Region = region + o.Credentials = aws.NewCredentialsCache(awscred.NewStaticCredentialsProvider(accessKeyId, secretAccessKey, "")) + }) + return client, nil +} diff --git a/ui/src/components/workflow/node/DeployNodeConfigForm.tsx b/ui/src/components/workflow/node/DeployNodeConfigForm.tsx index 33fefcf0..c0083298 100644 --- a/ui/src/components/workflow/node/DeployNodeConfigForm.tsx +++ b/ui/src/components/workflow/node/DeployNodeConfigForm.tsx @@ -35,6 +35,7 @@ import DeployNodeConfigFormAliyunVODConfig from "./DeployNodeConfigFormAliyunVOD import DeployNodeConfigFormAliyunWAFConfig from "./DeployNodeConfigFormAliyunWAFConfig"; import DeployNodeConfigFormAWSACMConfig from "./DeployNodeConfigFormAWSACMConfig"; import DeployNodeConfigFormAWSCloudFrontConfig from "./DeployNodeConfigFormAWSCloudFrontConfig"; +import DeployNodeConfigFormAWSIAMConfig from "./DeployNodeConfigFormAWSIAMConfig"; import DeployNodeConfigFormAzureKeyVaultConfig from "./DeployNodeConfigFormAzureKeyVaultConfig"; import DeployNodeConfigFormBaiduCloudAppBLBConfig from "./DeployNodeConfigFormBaiduCloudAppBLBConfig"; import DeployNodeConfigFormBaiduCloudBLBConfig from "./DeployNodeConfigFormBaiduCloudBLBConfig"; @@ -238,6 +239,8 @@ const DeployNodeConfigForm = forwardRef; case DEPLOYMENT_PROVIDERS.AWS_CLOUDFRONT: return ; + case DEPLOYMENT_PROVIDERS.AWS_IAM: + return ; case DEPLOYMENT_PROVIDERS.AZURE_KEYVAULT: return ; case DEPLOYMENT_PROVIDERS.BAIDUCLOUD_APPBLB: diff --git a/ui/src/components/workflow/node/DeployNodeConfigFormAWSCloudFrontConfig.tsx b/ui/src/components/workflow/node/DeployNodeConfigFormAWSCloudFrontConfig.tsx index f1689ced..a34f2fbb 100644 --- a/ui/src/components/workflow/node/DeployNodeConfigFormAWSCloudFrontConfig.tsx +++ b/ui/src/components/workflow/node/DeployNodeConfigFormAWSCloudFrontConfig.tsx @@ -1,11 +1,12 @@ import { useTranslation } from "react-i18next"; -import { Form, type FormInstance, Input } from "antd"; +import { Form, type FormInstance, Input, Select } from "antd"; import { createSchemaFieldRule } from "antd-zod"; import { z } from "zod"; type DeployNodeConfigFormAWSCloudFrontConfigFieldValues = Nullish<{ region: string; distributionId: string; + certificateSource: string; }>; export type DeployNodeConfigFormAWSCloudFrontConfigProps = { @@ -17,7 +18,9 @@ export type DeployNodeConfigFormAWSCloudFrontConfigProps = { }; const initFormModel = (): DeployNodeConfigFormAWSCloudFrontConfigFieldValues => { - return {}; + return { + certificateSource: "ACM", + }; }; const DeployNodeConfigFormAWSCloudFrontConfig = ({ @@ -30,15 +33,9 @@ const DeployNodeConfigFormAWSCloudFrontConfig = ({ const { t } = useTranslation(); const formSchema = z.object({ - region: z - .string({ message: t("workflow_node.deploy.form.aws_cloudfront_region.placeholder") }) - .nonempty(t("workflow_node.deploy.form.aws_cloudfront_region.placeholder")) - .trim(), - distributionId: z - .string({ message: t("workflow_node.deploy.form.aws_cloudfront_distribution_id.placeholder") }) - .nonempty(t("workflow_node.deploy.form.aws_cloudfront_distribution_id.placeholder")) - .max(64, t("common.errmsg.string_max", { max: 64 })) - .trim(), + region: z.string().trim().nonempty(t("workflow_node.deploy.form.aws_cloudfront_region.placeholder")), + distributionId: z.string().trim().nonempty(t("workflow_node.deploy.form.aws_cloudfront_distribution_id.placeholder")), + certificateSource: z.string().trim().nonempty(t("workflow_node.deploy.form.aws_cloudfront_certificate_source.placeholder")), }); const formRule = createSchemaFieldRule(formSchema); @@ -72,6 +69,17 @@ const DeployNodeConfigFormAWSCloudFrontConfig = ({ > + + + + ); }; diff --git a/ui/src/components/workflow/node/DeployNodeConfigFormAWSIAMConfig.tsx b/ui/src/components/workflow/node/DeployNodeConfigFormAWSIAMConfig.tsx new file mode 100644 index 00000000..1013153a --- /dev/null +++ b/ui/src/components/workflow/node/DeployNodeConfigFormAWSIAMConfig.tsx @@ -0,0 +1,77 @@ +import { useTranslation } from "react-i18next"; +import { Form, type FormInstance, Input } from "antd"; +import { createSchemaFieldRule } from "antd-zod"; +import { z } from "zod"; + +type DeployNodeConfigFormAWSIAMConfigFieldValues = Nullish<{ + region: string; + certificatePath?: string; +}>; + +export type DeployNodeConfigFormAWSIAMConfigProps = { + form: FormInstance; + formName: string; + disabled?: boolean; + initialValues?: DeployNodeConfigFormAWSIAMConfigFieldValues; + onValuesChange?: (values: DeployNodeConfigFormAWSIAMConfigFieldValues) => void; +}; + +const initFormModel = (): DeployNodeConfigFormAWSIAMConfigFieldValues => { + return { + certificatePath: "/", + }; +}; + +const DeployNodeConfigFormAWSIAMConfig = ({ form: formInst, formName, disabled, initialValues, onValuesChange }: DeployNodeConfigFormAWSIAMConfigProps) => { + const { t } = useTranslation(); + + const formSchema = z.object({ + region: z + .string({ message: t("workflow_node.deploy.form.aws_iam_region.placeholder") }) + .nonempty(t("workflow_node.deploy.form.aws_iam_region.placeholder")) + .trim(), + certificatePath: z + .string() + .nullish() + .refine((v) => { + if (!v) return true; + return v.startsWith("/") && v.endsWith("/"); + }, t("workflow_node.deploy.form.aws_iam_certificate_path.errmsg.invalid")), + }); + const formRule = createSchemaFieldRule(formSchema); + + const handleFormChange = (_: unknown, values: z.infer) => { + onValuesChange?.(values); + }; + + return ( +
+ } + > + + + + } + > + + +
+ ); +}; + +export default DeployNodeConfigFormAWSIAMConfig; diff --git a/ui/src/domain/provider.ts b/ui/src/domain/provider.ts index 64a52723..c74a13ba 100644 --- a/ui/src/domain/provider.ts +++ b/ui/src/domain/provider.ts @@ -389,6 +389,7 @@ export const DEPLOYMENT_PROVIDERS = Object.freeze({ ALIYUN_WAF: `${ACCESS_PROVIDERS.ALIYUN}-waf`, AWS_ACM: `${ACCESS_PROVIDERS.AWS}-acm`, AWS_CLOUDFRONT: `${ACCESS_PROVIDERS.AWS}-cloudfront`, + AWS_IAM: `${ACCESS_PROVIDERS.AWS}-iam`, AZURE_KEYVAULT: `${ACCESS_PROVIDERS.AZURE}-keyvault`, BAIDUCLOUD_APPBLB: `${ACCESS_PROVIDERS.BAIDUCLOUD}-appblb`, BAIDUCLOUD_BLB: `${ACCESS_PROVIDERS.BAIDUCLOUD}-blb`, @@ -561,6 +562,7 @@ export const deploymentProvidersMap: Maphttps://docs.aws.amazon.com/en_us/AmazonCloudFront/latest/DeveloperGuide/distribution-working-with.html", + "workflow_node.deploy.form.aws_cloudfront_certificate_source.label": "AWS CloudFront certificate source", + "workflow_node.deploy.form.aws_cloudfront_certificate_source.placeholder": "Please select AWS CloudFront certificate source", + "workflow_node.deploy.form.aws_iam_region.label": "AWS IAM Region", + "workflow_node.deploy.form.aws_iam_region.placeholder": "Please enter AWS IAM region (e.g. us-east-1)", + "workflow_node.deploy.form.aws_iam_region.tooltip": "For more information, see https://docs.aws.amazon.com/en_us/general/latest/gr/rande.html#regional-endpoints", + "workflow_node.deploy.form.aws_iam_certificate_path.label": "AWS IAM certificate path (Optional)", + "workflow_node.deploy.form.aws_iam_certificate_path.placeholder": "Please enter AWS IAM certificate path", + "workflow_node.deploy.form.aws_iam_certificate_path.errmsg.invalid": "Please enter a valid AWS IAM certificate path", + "workflow_node.deploy.form.aws_iam_certificate_path.tooltip": "For more information, see https://docs.aws.amazon.com/en_us/IAM/latest/UserGuide/reference_identifiers.html", "workflow_node.deploy.form.azure_keyvault_name.label": "Azure KeyVault name", "workflow_node.deploy.form.azure_keyvault_name.placeholder": "Please enter Azure KeyVault name", "workflow_node.deploy.form.azure_keyvault_name.tooltip": "For more information, see https://learn.microsoft.com/en-us/azure/key-vault/general/about-keys-secrets-certificates", diff --git a/ui/src/i18n/locales/zh/nls.provider.json b/ui/src/i18n/locales/zh/nls.provider.json index 8a9a5ec4..79af14fc 100644 --- a/ui/src/i18n/locales/zh/nls.provider.json +++ b/ui/src/i18n/locales/zh/nls.provider.json @@ -27,6 +27,7 @@ "provider.aws": "AWS", "provider.aws.acm": "AWS - ACM (Amazon Certificate Manager)", "provider.aws.cloudfront": "AWS - CloudFront", + "provider.aws.iam": "AWS - IAM (Identity and Access Management)", "provider.aws.route53": "AWS - Route53", "provider.azure": "Azure", "provider.azure.dns": "Azure - DNS", diff --git a/ui/src/i18n/locales/zh/nls.workflow.nodes.json b/ui/src/i18n/locales/zh/nls.workflow.nodes.json index 9f244ef2..9a8be8af 100644 --- a/ui/src/i18n/locales/zh/nls.workflow.nodes.json +++ b/ui/src/i18n/locales/zh/nls.workflow.nodes.json @@ -296,6 +296,15 @@ "workflow_node.deploy.form.aws_cloudfront_distribution_id.label": "AWS CloudFront 分配 ID", "workflow_node.deploy.form.aws_cloudfront_distribution_id.placeholder": "请输入 AWS CloudFront 分配 ID", "workflow_node.deploy.form.aws_cloudfront_distribution_id.tooltip": "这是什么?请参阅 https://docs.aws.amazon.com/zh_cn/AmazonCloudFront/latest/DeveloperGuide/distribution-working-with.html", + "workflow_node.deploy.form.aws_cloudfront_certificate_source.label": "AWS CloudFront 证书来源", + "workflow_node.deploy.form.aws_cloudfront_certificate_source.placeholder": "请选择 AWS CloudFront 证书来源", + "workflow_node.deploy.form.aws_iam_region.label": "AWS IAM 服务区域", + "workflow_node.deploy.form.aws_iam_region.placeholder": "请输入 AWS IAM 服务区域(例如:us-east-1)", + "workflow_node.deploy.form.aws_iam_region.tooltip": "这是什么?请参阅 https://docs.aws.amazon.com/zh_cn/general/latest/gr/rande.html#regional-endpoints", + "workflow_node.deploy.form.aws_iam_certificate_path.label": "AWS IAM 证书路径(可选)", + "workflow_node.deploy.form.aws_iam_certificate_path.placeholder": "请输入 AWS IAM 证书路径", + "workflow_node.deploy.form.aws_iam_certificate_path.errmsg.invalid": "请输入正确的 AWS IAM 证书路径", + "workflow_node.deploy.form.aws_iam_certificate_path.tooltip": "这是什么?请参阅 https://docs.aws.amazon.com/zh_cn/IAM/latest/UserGuide/reference_identifiers.html", "workflow_node.deploy.form.azure_keyvault_name.label": "Azure KeyVault 名称", "workflow_node.deploy.form.azure_keyvault_name.placeholder": "请输入 Azure KeyVault 名称", "workflow_node.deploy.form.azure_keyvault_name.tooltip": "这是什么?请参阅 https://learn.microsoft.com/zh-cn/azure/key-vault/general/about-keys-secrets-certificates", From 9ad0e6fb57635a3b8a2f883447f6f88437c94558 Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Tue, 3 Jun 2025 23:39:27 +0800 Subject: [PATCH 4/5] feat: support ssh challenge-response --- internal/deployer/providers.go | 2 + internal/domain/access.go | 6 +- .../pkg/core/deployer/providers/ssh/ssh.go | 77 +++++- migrations/1748959200_upgrade.go | 62 +++++ .../components/access/AccessFormSSHConfig.tsx | 240 ++++++++++-------- ui/src/domain/access.ts | 3 +- ui/src/i18n/locales/en/nls.access.json | 13 +- ui/src/i18n/locales/zh/nls.access.json | 13 +- 8 files changed, 278 insertions(+), 138 deletions(-) create mode 100644 migrations/1748959200_upgrade.go diff --git a/internal/deployer/providers.go b/internal/deployer/providers.go index 6f02c97a..06239710 100644 --- a/internal/deployer/providers.go +++ b/internal/deployer/providers.go @@ -997,6 +997,7 @@ func createDeployerProvider(options *deployerProviderOptions) (deployer.Deployer jumpServers[i] = pSSH.JumpServerConfig{ SshHost: jumpServer.Host, SshPort: jumpServer.Port, + SshAuthMethod: jumpServer.AuthMethod, SshUsername: jumpServer.Username, SshPassword: jumpServer.Password, SshKey: jumpServer.Key, @@ -1007,6 +1008,7 @@ func createDeployerProvider(options *deployerProviderOptions) (deployer.Deployer deployer, err := pSSH.NewDeployer(&pSSH.DeployerConfig{ SshHost: access.Host, SshPort: access.Port, + SshAuthMethod: access.AuthMethod, SshUsername: access.Username, SshPassword: access.Password, SshKey: access.Key, diff --git a/internal/domain/access.go b/internal/domain/access.go index 274f2fd0..c6071aef 100644 --- a/internal/domain/access.go +++ b/internal/domain/access.go @@ -315,14 +315,16 @@ type AccessConfigForSlackBot struct { type AccessConfigForSSH struct { Host string `json:"host"` Port int32 `json:"port"` - Username string `json:"username"` + AuthMethod string `json:"authMethod,omitempty"` + Username string `json:"username,omitempty"` Password string `json:"password,omitempty"` Key string `json:"key,omitempty"` KeyPassphrase string `json:"keyPassphrase,omitempty"` JumpServers []struct { Host string `json:"host"` Port int32 `json:"port"` - Username string `json:"username"` + AuthMethod string `json:"authMethod,omitempty"` + Username string `json:"username,omitempty"` Password string `json:"password,omitempty"` Key string `json:"key,omitempty"` KeyPassphrase string `json:"keyPassphrase,omitempty"` diff --git a/internal/pkg/core/deployer/providers/ssh/ssh.go b/internal/pkg/core/deployer/providers/ssh/ssh.go index a52c355e..c68d935b 100644 --- a/internal/pkg/core/deployer/providers/ssh/ssh.go +++ b/internal/pkg/core/deployer/providers/ssh/ssh.go @@ -24,7 +24,12 @@ type JumpServerConfig struct { // SSH 端口。 // 零值时默认值 22。 SshPort int32 `json:"sshPort,omitempty"` + // SSH 认证方式。 + // 可取值 "none"、"password"、"key"。 + // 零值时根据有无密码或私钥字段决定。 + SshAuthMethod string `json:"sshAuthMethod,omitempty"` // SSH 登录用户名。 + // 零值时默认值 "root"。 SshUsername string `json:"sshUsername,omitempty"` // SSH 登录密码。 SshPassword string `json:"sshPassword,omitempty"` @@ -41,7 +46,12 @@ type DeployerConfig struct { // SSH 端口。 // 零值时默认值 22。 SshPort int32 `json:"sshPort,omitempty"` + // SSH 认证方式。 + // 可取值 "none"、"password" 或 "key"。 + // 零值时根据有无密码或私钥字段决定。 + SshAuthMethod string `json:"sshAuthMethod,omitempty"` // SSH 登录用户名。 + // 零值时默认值 "root"。 SshUsername string `json:"sshUsername,omitempty"` // SSH 登录密码。 SshPassword string `json:"sshPassword,omitempty"` @@ -141,6 +151,7 @@ func (d *DeployerProvider) Deploy(ctx context.Context, certPEM string, privkeyPE jumpConn, jumpServerConf.SshHost, jumpServerConf.SshPort, + jumpServerConf.SshAuthMethod, jumpServerConf.SshUsername, jumpServerConf.SshPassword, jumpServerConf.SshKey, @@ -174,6 +185,7 @@ func (d *DeployerProvider) Deploy(ctx context.Context, certPEM string, privkeyPE targetConn, d.config.SshHost, d.config.SshPort, + d.config.SshAuthMethod, d.config.SshUsername, d.config.SshPassword, d.config.SshKey, @@ -262,7 +274,7 @@ func (d *DeployerProvider) Deploy(ctx context.Context, certPEM string, privkeyPE return &deployer.DeployResult{}, nil } -func createSshClient(conn net.Conn, host string, port int32, username string, password string, key string, keyPassphrase string) (*ssh.Client, error) { +func createSshClient(conn net.Conn, host string, port int32, authMethod string, username, password, key, keyPassphrase string) (*ssh.Client, error) { if host == "" { host = "localhost" } @@ -271,28 +283,65 @@ func createSshClient(conn net.Conn, host string, port int32, username string, pa port = 22 } - var authMethod ssh.AuthMethod - if key != "" { - var signer ssh.Signer - var err error + if username == "" { + username = "root" + } - if keyPassphrase != "" { - signer, err = ssh.ParsePrivateKeyWithPassphrase([]byte(key), []byte(keyPassphrase)) + const AUTH_METHOD_NONE = "none" + const AUTH_METHOD_PASSWORD = "password" + const AUTH_METHOD_KEY = "key" + if authMethod == "" { + if key != "" { + authMethod = AUTH_METHOD_KEY + } else if password != "" { + authMethod = AUTH_METHOD_PASSWORD } else { - signer, err = ssh.ParsePrivateKey([]byte(key)) + authMethod = AUTH_METHOD_NONE + } + } + + authentications := make([]ssh.AuthMethod, 0) + switch authMethod { + case AUTH_METHOD_NONE: + { } - if err != nil { - return nil, err + case AUTH_METHOD_PASSWORD: + { + authentications = append(authentications, ssh.Password(password)) + authentications = append(authentications, ssh.KeyboardInteractive(func(user, instruction string, questions []string, echos []bool) ([]string, error) { + if len(questions) == 1 { + return []string{password}, nil + } + return nil, fmt.Errorf("unexpected keyboard interactive question: %s", questions[0]) + })) } - authMethod = ssh.PublicKeys(signer) - } else { - authMethod = ssh.Password(password) + + case AUTH_METHOD_KEY: + { + var signer ssh.Signer + var err error + + if keyPassphrase != "" { + signer, err = ssh.ParsePrivateKeyWithPassphrase([]byte(key), []byte(keyPassphrase)) + } else { + signer, err = ssh.ParsePrivateKey([]byte(key)) + } + + if err != nil { + return nil, err + } + + authentications = append(authentications, ssh.PublicKeys(signer)) + } + + default: + return nil, fmt.Errorf("unsupported auth method '%s'", authMethod) } sshConn, chans, reqs, err := ssh.NewClientConn(conn, fmt.Sprintf("%s:%d", host, port), &ssh.ClientConfig{ User: username, - Auth: []ssh.AuthMethod{authMethod}, + Auth: authentications, HostKeyCallback: ssh.InsecureIgnoreHostKey(), }) if err != nil { diff --git a/migrations/1748959200_upgrade.go b/migrations/1748959200_upgrade.go new file mode 100644 index 00000000..daa6b715 --- /dev/null +++ b/migrations/1748959200_upgrade.go @@ -0,0 +1,62 @@ +package migrations + +import ( + "github.com/pocketbase/pocketbase/core" + m "github.com/pocketbase/pocketbase/migrations" +) + +func init() { + m.Register(func(app core.App) error { + tracer := NewTracer("(v0.3)1748959200") + tracer.Printf("go ...") + + // migrate data + { + collection, err := app.FindCollectionByNameOrId("4yzbv8urny5ja1e") + if err != nil { + return err + } + + records, err := app.FindAllRecords(collection) + if err != nil { + return err + } + + for _, record := range records { + changed := false + + if record.GetString("provider") == "ssh" { + config := make(map[string]any) + if err := record.UnmarshalJSONField("config", &config); err != nil { + return err + } + + if config["authMethod"] == nil || config["authMethod"] == "" { + if config["key"] != nil && config["key"] != "" { + config["authMethod"] = "key" + } else if config["password"] != nil && config["password"] != "" { + config["authMethod"] = "password" + } else { + config["authMethod"] = "none" + } + record.Set("config", config) + changed = true + } + } + + if changed { + if err := app.Save(record); err != nil { + return err + } + + tracer.Printf("record #%s in collection '%s' updated", record.Id, collection.Name) + } + } + } + + tracer.Printf("done") + return nil + }, func(app core.App) error { + return nil + }) +} diff --git a/ui/src/components/access/AccessFormSSHConfig.tsx b/ui/src/components/access/AccessFormSSHConfig.tsx index 84b67e32..56532771 100644 --- a/ui/src/components/access/AccessFormSSHConfig.tsx +++ b/ui/src/components/access/AccessFormSSHConfig.tsx @@ -1,9 +1,10 @@ import { useTranslation } from "react-i18next"; import { ArrowDownOutlined, ArrowUpOutlined, CloseOutlined, PlusOutlined } from "@ant-design/icons"; -import { Button, Collapse, Form, type FormInstance, Input, InputNumber, Space } from "antd"; +import { Button, Collapse, Form, type FormInstance, Input, InputNumber, Select, Space } from "antd"; import { createSchemaFieldRule } from "antd-zod"; import { z } from "zod"; +import Show from "@/components/Show"; import TextFileInput from "@/components/TextFileInput"; import { type AccessConfigForSSH } from "@/domain/access"; import { validDomainName, validIPv4Address, validIPv6Address, validPortNumber } from "@/utils/validators"; @@ -18,10 +19,15 @@ export type AccessFormSSHConfigProps = { onValuesChange?: (values: AccessFormSSHConfigFieldValues) => void; }; +const AUTH_METHOD_NONE = "none" as const; +const AUTH_METHOD_PASSWORD = "password" as const; +const AUTH_METHOD_KEY = "key" as const; + const initFormModel = (): AccessFormSSHConfigFieldValues => { return { host: "127.0.0.1", port: 22, + authMethod: AUTH_METHOD_PASSWORD, username: "root", }; }; @@ -38,6 +44,9 @@ const AccessFormSSHConfig = ({ form: formInst, formName, disabled, initialValues .int(t("access.form.ssh_port.placeholder")) .refine((v) => validPortNumber(v), t("common.errmsg.port_invalid")) ), + authMethod: z.union([z.literal(AUTH_METHOD_NONE), z.literal(AUTH_METHOD_PASSWORD), z.literal(AUTH_METHOD_KEY)], { + message: t("access.form.ssh_auth_method.placeholder"), + }), username: z .string() .min(1, t("access.form.ssh_username.placeholder")) @@ -45,11 +54,13 @@ const AccessFormSSHConfig = ({ form: formInst, formName, disabled, initialValues password: z .string() .max(64, t("common.errmsg.string_max", { max: 64 })) - .nullish(), + .nullish() + .refine((v) => fieldAuthMethod !== AUTH_METHOD_PASSWORD || !!v?.trim(), t("access.form.ssh_password.placeholder")), key: z .string() .max(20480, t("common.errmsg.string_max", { max: 20480 })) - .nullish(), + .nullish() + .refine((v) => fieldAuthMethod !== AUTH_METHOD_KEY || !!v?.trim(), t("access.form.ssh_key.placeholder")), keyPassphrase: z .string() .max(20480, t("common.errmsg.string_max", { max: 20480 })) @@ -57,47 +68,43 @@ const AccessFormSSHConfig = ({ form: formInst, formName, disabled, initialValues .refine((v) => !v || formInst.getFieldValue("key"), t("access.form.ssh_key.placeholder")), jumpServers: z .array( - z - .object({ - host: z.string().refine((v) => validDomainName(v) || validIPv4Address(v) || validIPv6Address(v), t("common.errmsg.host_invalid")), - port: z.preprocess( - (v) => Number(v), - z - .number() - .int(t("access.form.ssh_port.placeholder")) - .refine((v) => validPortNumber(v), t("common.errmsg.port_invalid")) - ), - username: z - .string() - .min(1, t("access.form.ssh_username.placeholder")) - .max(64, t("common.errmsg.string_max", { max: 64 })), - password: z - .string() - .max(64, t("common.errmsg.string_max", { max: 64 })) - .nullish(), - key: z - .string() - .max(20480, t("common.errmsg.string_max", { max: 20480 })) - .nullish(), - keyPassphrase: z - .string() - .max(20480, t("common.errmsg.string_max", { max: 20480 })) - .nullish(), - }) - .superRefine((data, ctx) => { - if (data.keyPassphrase && !data.key) { - ctx.addIssue({ - path: ["keyPassphrase"], - code: z.ZodIssueCode.custom, - message: t("access.form.ssh_key.placeholder"), - }); - } - }) + z.object({ + host: z.string().refine((v) => validDomainName(v) || validIPv4Address(v) || validIPv6Address(v), t("common.errmsg.host_invalid")), + port: z.preprocess( + (v) => Number(v), + z + .number() + .int(t("access.form.ssh_port.placeholder")) + .refine((v) => validPortNumber(v), t("common.errmsg.port_invalid")) + ), + authMethod: z.union([z.literal(AUTH_METHOD_NONE), z.literal(AUTH_METHOD_PASSWORD), z.literal(AUTH_METHOD_KEY)], { + message: t("access.form.ssh_auth_method.placeholder"), + }), + username: z + .string() + .min(1, t("access.form.ssh_username.placeholder")) + .max(64, t("common.errmsg.string_max", { max: 64 })), + password: z + .string() + .max(64, t("common.errmsg.string_max", { max: 64 })) + .nullish(), + key: z + .string() + .max(20480, t("common.errmsg.string_max", { max: 20480 })) + .nullish(), + keyPassphrase: z + .string() + .max(20480, t("common.errmsg.string_max", { max: 20480 })) + .nullish(), + }), + { message: t("access.form.ssh_jump_servers.errmsg.invalid") } ) .nullish(), }); const formRule = createSchemaFieldRule(formSchema); + const fieldAuthMethod = Form.useWatch("authMethod", formInst); + const handleFormChange = (_: unknown, values: z.infer) => { onValuesChange?.(values); }; @@ -125,36 +132,39 @@ const AccessFormSSHConfig = ({ form: formInst, formName, disabled, initialValues + + + + - } - > - - + + + + + - } - > - - + + + + - } - > - - + + + + @@ -174,6 +184,60 @@ const AccessFormSSHConfig = ({ form: formInst, formName, disabled, initialValues ); }; + const Fields = () => { + const authMethod = Form.useWatch(["jumpServers", field.name, "authMethod"], formInst); + return ( + <> +
+
+ + + +
+
+ + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + ); + }; + return { key: field.key, label: