From 83bb9a7eae9efa04285e5f9b74090566234c2e81 Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Thu, 27 Aug 2026 17:14:32 +0800 Subject: [PATCH] feat(provider): new purge provider: aws acm --- internal/certmgmt/purgers/sp_aws_acm.go | 27 ++ internal/domain/provider.go | 1 + .../deployer/providers/aws-alb/aws_alb.go | 232 +++++++++++++++++- .../deployer/providers/aws-nlb/aws_nlb.go | 232 +++++++++++++++++- .../providers-tester/aws-acm/aws_acm_test.go | 48 ++++ pkg/core/purger/providers/aws-acm/aws_acm.go | 187 ++++++++++++++ pkg/core/purger/providers/aws-acm/consts.go | 10 + .../BizPurgeNodeConfigFieldsProvider.tsx | 2 + ...BizPurgeNodeConfigFieldsProviderAWSACM.tsx | 52 ++++ ui/src/domain/provider.ts | 2 + .../i18n/resources/en/nls.workflow.nodes.json | 5 + .../i18n/resources/zh/nls.workflow.nodes.json | 5 + 12 files changed, 787 insertions(+), 16 deletions(-) create mode 100644 internal/certmgmt/purgers/sp_aws_acm.go create mode 100644 pkg/core/purger/providers-tester/aws-acm/aws_acm_test.go create mode 100644 pkg/core/purger/providers/aws-acm/aws_acm.go create mode 100644 pkg/core/purger/providers/aws-acm/consts.go create mode 100644 ui/src/components/workflow/designer/forms/BizPurgeNodeConfigFieldsProviderAWSACM.tsx diff --git a/internal/certmgmt/purgers/sp_aws_acm.go b/internal/certmgmt/purgers/sp_aws_acm.go new file mode 100644 index 000000000..d849fdbaa --- /dev/null +++ b/internal/certmgmt/purgers/sp_aws_acm.go @@ -0,0 +1,27 @@ +package purgers + +import ( + "fmt" + + "github.com/certimate-go/certimate/internal/domain" + "github.com/certimate-go/certimate/pkg/core" + pgrimpl "github.com/certimate-go/certimate/pkg/core/purger/providers/aws-acm" + xmaps "github.com/certimate-go/certimate/pkg/utils/maps" +) + +func init() { + Registries.MustRegister(domain.PurgeProviderTypeAWSACM, func(options *ProviderFactoryOptions) (core.Purger, error) { + credentials := domain.AccessConfigForAWS{} + if err := xmaps.Populate(options.ProviderAccessConfig, &credentials); err != nil { + return nil, fmt.Errorf("failed to populate provider access config: %w", err) + } + + provider, err := pgrimpl.NewPurger(&pgrimpl.PurgerConfig{ + AuthMethod: credentials.AuthMethod, + AccessKeyId: credentials.AccessKeyId, + SecretAccessKey: credentials.SecretAccessKey, + Region: xmaps.GetString(options.ProviderExtendedConfig, "region"), + }) + return provider, err + }) +} diff --git a/internal/domain/provider.go b/internal/domain/provider.go index e727ff62c..6dc8ccebc 100644 --- a/internal/domain/provider.go +++ b/internal/domain/provider.go @@ -495,6 +495,7 @@ const ( PurgeProviderTypeAliyunCAS = PurgeProviderType(AccessProviderTypeAliyun + "-cas") PurgeProviderTypeAliyunCLB = PurgeProviderType(AccessProviderTypeAliyun + "-clb") PurgeProviderTypeAliyunESA = PurgeProviderType(AccessProviderTypeAliyun + "-esa") + PurgeProviderTypeAWSACM = PurgeProviderType(AccessProviderTypeAWS + "-acm") PurgeProviderTypeAWSIAM = PurgeProviderType(AccessProviderTypeAWS + "-iam") PurgeProviderTypeAzureKeyVault = PurgeProviderType(AccessProviderTypeAzure + "-keyvault") PurgeProviderTypeBaiduCloudCert = PurgeProviderType(AccessProviderTypeBaiduCloud + "-cert") diff --git a/pkg/core/deployer/providers/aws-alb/aws_alb.go b/pkg/core/deployer/providers/aws-alb/aws_alb.go index 15a784e79..a8da24164 100644 --- a/pkg/core/deployer/providers/aws-alb/aws_alb.go +++ b/pkg/core/deployer/providers/aws-alb/aws_alb.go @@ -2,19 +2,26 @@ package awsalb import ( "context" + "errors" "fmt" "log/slog" + "strings" 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" "github.com/aws/aws-sdk-go-v2/credentials/ec2rolecreds" + "github.com/aws/aws-sdk-go-v2/service/acm" "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2" "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2/types" + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/aws/smithy-go" + "github.com/samber/lo" "github.com/certimate-go/certimate/pkg/core" cmgrimplacm "github.com/certimate-go/certimate/pkg/core/certmgr/providers/aws-acm" cmgrimpliam "github.com/certimate-go/certimate/pkg/core/certmgr/providers/aws-iam" + xcert "github.com/certimate-go/certimate/pkg/utils/cert" ) type ( @@ -47,18 +54,34 @@ type DeployerConfig struct { type Deployer struct { config *DeployerConfig logger *slog.Logger - sdkClient *elasticloadbalancingv2.Client + sdkClients *wSDKClients sdkCertmgr core.Certmgr } var _ Provider = (*Deployer)(nil) +type wSDKClients struct { + ACM *acm.Client + IAM *iam.Client + ELB *elasticloadbalancingv2.Client +} + func NewDeployer(config *DeployerConfig) (*Deployer, error) { if config == nil { return nil, fmt.Errorf("the configuration of the deployer provider is nil") } - client, err := createSDKClient(config.AuthMethod, config.AccessKeyId, config.SecretAccessKey, config.Region) + clientELB, err := createSDKClientELB(config.AuthMethod, config.AccessKeyId, config.SecretAccessKey, config.Region) + if err != nil { + return nil, fmt.Errorf("could not create client: %w", err) + } + + clientACM, err := createSDKClientACM(config.AuthMethod, config.AccessKeyId, config.SecretAccessKey, config.Region) + if err != nil { + return nil, fmt.Errorf("could not create client: %w", err) + } + + clientIAM, err := createSDKClientIAM(config.AuthMethod, config.AccessKeyId, config.SecretAccessKey, config.Region) if err != nil { return nil, fmt.Errorf("could not create client: %w", err) } @@ -95,7 +118,7 @@ func NewDeployer(config *DeployerConfig) (*Deployer, error) { return &Deployer{ config: config, logger: slog.Default(), - sdkClient: client, + sdkClients: &wSDKClients{ELB: clientELB, ACM: clientACM, IAM: clientIAM}, sdkCertmgr: pcertmgr, }, nil } @@ -118,6 +141,12 @@ func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*Dep return nil, fmt.Errorf("config `listenerArn` is required") } + // 解析证书内容 + certX509, err := xcert.ParseCertificateFromPEM(certPEM) + if err != nil { + return nil, err + } + // 上传证书 upres, err := d.sdkCertmgr.Upload(ctx, certPEM, privkeyPEM) if err != nil { @@ -131,7 +160,7 @@ func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*Dep describeLoadBalancersReq := &elasticloadbalancingv2.DescribeLoadBalancersInput{ LoadBalancerArns: []string{d.config.LoadbalancerArn}, } - describeLoadBalancersResp, err := d.sdkClient.DescribeLoadBalancers(ctx, describeLoadBalancersReq) + describeLoadBalancersResp, err := d.sdkClients.ELB.DescribeLoadBalancers(ctx, describeLoadBalancersReq) d.logger.Debug("sdk request 'elasticloadbalancingv2.DescribeLoadBalancers'", slog.Any("request", describeLoadBalancersReq), slog.Any("response", describeLoadBalancersResp)) if err != nil { return nil, fmt.Errorf("failed to execute sdk request 'elasticloadbalancingv2.DescribeLoadBalancers': %w", err) @@ -145,7 +174,7 @@ func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*Dep LoadBalancerArn: aws.String(d.config.LoadbalancerArn), ListenerArns: []string{d.config.ListenerArn}, } - describeListenersResp, err := d.sdkClient.DescribeListeners(ctx, describeListenersReq) + describeListenersResp, err := d.sdkClients.ELB.DescribeListeners(ctx, describeListenersReq) d.logger.Debug("sdk request 'elasticloadbalancingv2.DescribeListeners'", slog.Any("request", describeListenersReq), slog.Any("response", describeListenersResp)) if err != nil { return nil, fmt.Errorf("failed to execute sdk request 'elasticloadbalancingv2.DescribeListeners': %w", err) @@ -182,6 +211,10 @@ func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*Dep if err := d.updateListenerSniCertificate(ctx, *listenerInfo.ListenerArn, certArn); err != nil { return nil, err } + + if err := d.pruneListenerSniCertificate(ctx, *listenerInfo.ListenerArn, certArn, certX509.DNSNames); err != nil { + return nil, err + } } return &DeployResult{}, nil @@ -198,7 +231,7 @@ func (d *Deployer) updateListenerDefaultCertificate(ctx context.Context, cloudLi }, }, } - modifyListenerResp, err := d.sdkClient.ModifyListener(ctx, modifyListenerReq) + modifyListenerResp, err := d.sdkClients.ELB.ModifyListener(ctx, modifyListenerReq) d.logger.Debug("sdk request 'elasticloadbalancingv2.ModifyListener'", slog.Any("request", modifyListenerReq), slog.Any("response", modifyListenerResp)) if err != nil { return fmt.Errorf("failed to execute sdk request 'elasticloadbalancingv2.ModifyListener': %w", err) @@ -218,7 +251,7 @@ func (d *Deployer) updateListenerSniCertificate(ctx context.Context, cloudListen }, }, } - addListenerCertificatesResp, err := d.sdkClient.AddListenerCertificates(ctx, addListenerCertificatesReq) + addListenerCertificatesResp, err := d.sdkClients.ELB.AddListenerCertificates(ctx, addListenerCertificatesReq) d.logger.Debug("sdk request 'elasticloadbalancingv2.AddListenerCertificates'", slog.Any("request", addListenerCertificatesReq), slog.Any("response", addListenerCertificatesResp)) if err != nil { return fmt.Errorf("failed to execute sdk request 'elasticloadbalancingv2.AddListenerCertificates': %w", err) @@ -227,7 +260,132 @@ func (d *Deployer) updateListenerSniCertificate(ctx context.Context, cloudListen return nil } -func createSDKClient(authMethod, accessKeyId, secretAccessKey, region string) (*elasticloadbalancingv2.Client, error) { +func (d *Deployer) pruneListenerSniCertificate(ctx context.Context, cloudListenerArn string, cloudCertArn string, cloudCertSANs []string) error { + const acmArnPrefix = "arn:aws:acm:" + const iamArnPrefix = "arn:aws:iam:" + + certArnsToDelete := make([]string, 0) + + // 查询侦听器证书列表,并找出需要删除绑定的同域名证书 + // REF: https://docs.aws.amazon.com/elasticloadbalancing/latest/APIReference/API_DescribeListenerCertificates.html + // REF: https://docs.aws.amazon.com/IAM/latest/APIReference/API_GetServerCertificate.html + // REF: https://docs.aws.amazon.com/acm/latest/APIReference/API_GetCertificate.html + describeListenerCertificatesMarker := (*string)(nil) + describeListenerCertificatesPageSize := 100 + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + describeListenerCertificatesReq := &elasticloadbalancingv2.DescribeListenerCertificatesInput{ + ListenerArn: aws.String(cloudListenerArn), + Marker: describeListenerCertificatesMarker, + PageSize: aws.Int32(int32(describeListenerCertificatesPageSize)), + } + describeListenerCertificatesResp, err := d.sdkClients.ELB.DescribeListenerCertificates(ctx, describeListenerCertificatesReq) + d.logger.Debug("sdk request 'elasticloadbalancingv2.DescribeListenerCertificates'", slog.Any("request", describeListenerCertificatesReq), slog.Any("response", describeListenerCertificatesResp)) + if err != nil { + return fmt.Errorf("failed to execute sdk request 'elasticloadbalancingv2.DescribeListenerCertificates': %w", err) + } + + for _, certItem := range describeListenerCertificatesResp.Certificates { + if aws.ToString(certItem.CertificateArn) == cloudCertArn { + continue + } + if aws.ToBool(certItem.IsDefault) { + continue + } + + switch { + case strings.HasPrefix(aws.ToString(certItem.CertificateArn), acmArnPrefix): + { + getCertificateReq := &acm.GetCertificateInput{ + CertificateArn: certItem.CertificateArn, + } + getCertificateResp, err := d.sdkClients.ACM.GetCertificate(ctx, getCertificateReq) + if err != nil { + var sdkerr smithy.APIError + if errors.As(err, &sdkerr) { + if sdkErrCode := sdkerr.ErrorCode(); sdkErrCode == "InvalidArnException" || sdkErrCode == "ResourceNotFoundException" { + continue + } + } + + return fmt.Errorf("failed to execute sdk request 'acm.GetCertificate': %w", err) + } + + certX509InACM, err := xcert.ParseCertificateFromPEM(aws.ToString(getCertificateResp.Certificate)) + if err != nil { + continue + } + + if lo.ElementsMatch(cloudCertSANs, certX509InACM.DNSNames) { + certArnsToDelete = append(certArnsToDelete, aws.ToString(certItem.CertificateArn)) + } + } + + case strings.HasPrefix(aws.ToString(certItem.CertificateArn), iamArnPrefix): + { + getServerCertificateReq := &iam.GetServerCertificateInput{ + ServerCertificateName: certItem.CertificateArn, + } + getServerCertificateResp, err := d.sdkClients.IAM.GetServerCertificate(ctx, getServerCertificateReq) + if err != nil { + var sdkerr smithy.APIError + if errors.As(err, &sdkerr) { + if sdkerr.ErrorCode() == "NoSuchEntity" { + continue + } + } + + return fmt.Errorf("failed to execute sdk request 'iam.GetServerCertificate': %w", err) + } + + certX509InIAM, err := xcert.ParseCertificateFromPEM(aws.ToString(getServerCertificateResp.ServerCertificate.CertificateBody)) + if err != nil { + continue + } + + if lo.ElementsMatch(cloudCertSANs, certX509InIAM.DNSNames) { + certArnsToDelete = append(certArnsToDelete, aws.ToString(certItem.CertificateArn)) + } + } + } + } + + if len(describeListenerCertificatesResp.Certificates) < describeListenerCertificatesPageSize || describeListenerCertificatesResp.NextMarker == nil { + break + } + + describeListenerCertificatesMarker = describeListenerCertificatesResp.NextMarker + } + + // 从证书列表删除证书 + // REF: https://docs.aws.amazon.com/elasticloadbalancing/latest/APIReference/API_RemoveListenerCertificates.html + if len(certArnsToDelete) > 0 { + d.logger.Info("found alb listener certificates to delete", slog.Any("certificateArns", certArnsToDelete)) + + removeListenerCertificatesReq := &elasticloadbalancingv2.RemoveListenerCertificatesInput{ + ListenerArn: aws.String(d.config.ListenerArn), + Certificates: lo.Map(certArnsToDelete, func(certArn string, _ int) types.Certificate { + return types.Certificate{ + CertificateArn: aws.String(certArn), + } + }), + } + removeListenerCertificatesResp, err := d.sdkClients.ELB.RemoveListenerCertificates(ctx, removeListenerCertificatesReq) + d.logger.Debug("sdk request 'elasticloadbalancingv2.RemoveListenerCertificates'", slog.Any("request", removeListenerCertificatesReq), slog.Any("response", removeListenerCertificatesResp)) + if err != nil { + return fmt.Errorf("failed to execute sdk request 'elasticloadbalancingv2.RemoveListenerCertificates': %w", err) + } + } + + return nil +} + +func createSDKClientELB(authMethod, accessKeyId, secretAccessKey, region string) (*elasticloadbalancingv2.Client, error) { opts := []func(options *awscfg.LoadOptions) error{ awscfg.WithRegion(region), } @@ -255,3 +413,61 @@ func createSDKClient(authMethod, accessKeyId, secretAccessKey, region string) (* client := elasticloadbalancingv2.NewFromConfig(cfg) return client, nil } + +func createSDKClientACM(authMethod, accessKeyId, secretAccessKey, region string) (*acm.Client, error) { + opts := []func(options *awscfg.LoadOptions) error{ + awscfg.WithRegion(region), + } + + staticCredsProvider := awscred.NewStaticCredentialsProvider(accessKeyId, secretAccessKey, "") + imdsCredsProvider := aws.NewCredentialsCache(ec2rolecreds.New()) + switch authMethod { + case "": + if accessKeyId != "" && secretAccessKey != "" { + opts = append(opts, awscfg.WithCredentialsProvider(staticCredsProvider)) + } + case AUTH_METHOD_ACCESSKEY: + opts = append(opts, awscfg.WithCredentialsProvider(staticCredsProvider)) + case AUTH_METHOD_IMDS: + opts = append(opts, awscfg.WithCredentialsProvider(imdsCredsProvider)) + default: + return nil, fmt.Errorf("unsupported auth method '%s'", authMethod) + } + + cfg, err := awscfg.LoadDefaultConfig(context.Background(), opts...) + if err != nil { + return nil, err + } + + client := acm.NewFromConfig(cfg) + return client, nil +} + +func createSDKClientIAM(authMethod, accessKeyId, secretAccessKey, region string) (*iam.Client, error) { + opts := []func(options *awscfg.LoadOptions) error{ + awscfg.WithRegion(region), + } + + staticCredsProvider := awscred.NewStaticCredentialsProvider(accessKeyId, secretAccessKey, "") + imdsCredsProvider := aws.NewCredentialsCache(ec2rolecreds.New()) + switch authMethod { + case "": + if accessKeyId != "" && secretAccessKey != "" { + opts = append(opts, awscfg.WithCredentialsProvider(staticCredsProvider)) + } + case AUTH_METHOD_ACCESSKEY: + opts = append(opts, awscfg.WithCredentialsProvider(staticCredsProvider)) + case AUTH_METHOD_IMDS: + opts = append(opts, awscfg.WithCredentialsProvider(imdsCredsProvider)) + default: + return nil, fmt.Errorf("unsupported auth method '%s'", authMethod) + } + + cfg, err := awscfg.LoadDefaultConfig(context.Background(), opts...) + if err != nil { + return nil, err + } + + client := iam.NewFromConfig(cfg) + return client, nil +} diff --git a/pkg/core/deployer/providers/aws-nlb/aws_nlb.go b/pkg/core/deployer/providers/aws-nlb/aws_nlb.go index 5ce06ff26..d811314ce 100644 --- a/pkg/core/deployer/providers/aws-nlb/aws_nlb.go +++ b/pkg/core/deployer/providers/aws-nlb/aws_nlb.go @@ -2,19 +2,26 @@ package awsnlb import ( "context" + "errors" "fmt" "log/slog" + "strings" 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" "github.com/aws/aws-sdk-go-v2/credentials/ec2rolecreds" + "github.com/aws/aws-sdk-go-v2/service/acm" "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2" "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2/types" + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/aws/smithy-go" + "github.com/samber/lo" "github.com/certimate-go/certimate/pkg/core" cmgrimplacm "github.com/certimate-go/certimate/pkg/core/certmgr/providers/aws-acm" cmgrimpliam "github.com/certimate-go/certimate/pkg/core/certmgr/providers/aws-iam" + xcert "github.com/certimate-go/certimate/pkg/utils/cert" ) type ( @@ -47,18 +54,34 @@ type DeployerConfig struct { type Deployer struct { config *DeployerConfig logger *slog.Logger - sdkClient *elasticloadbalancingv2.Client + sdkClients *wSDKClients sdkCertmgr core.Certmgr } var _ Provider = (*Deployer)(nil) +type wSDKClients struct { + ACM *acm.Client + IAM *iam.Client + ELB *elasticloadbalancingv2.Client +} + func NewDeployer(config *DeployerConfig) (*Deployer, error) { if config == nil { return nil, fmt.Errorf("the configuration of the deployer provider is nil") } - client, err := createSDKClient(config.AuthMethod, config.AccessKeyId, config.SecretAccessKey, config.Region) + clientELB, err := createSDKClientELB(config.AuthMethod, config.AccessKeyId, config.SecretAccessKey, config.Region) + if err != nil { + return nil, fmt.Errorf("could not create client: %w", err) + } + + clientACM, err := createSDKClientACM(config.AuthMethod, config.AccessKeyId, config.SecretAccessKey, config.Region) + if err != nil { + return nil, fmt.Errorf("could not create client: %w", err) + } + + clientIAM, err := createSDKClientIAM(config.AuthMethod, config.AccessKeyId, config.SecretAccessKey, config.Region) if err != nil { return nil, fmt.Errorf("could not create client: %w", err) } @@ -95,7 +118,7 @@ func NewDeployer(config *DeployerConfig) (*Deployer, error) { return &Deployer{ config: config, logger: slog.Default(), - sdkClient: client, + sdkClients: &wSDKClients{ELB: clientELB, ACM: clientACM, IAM: clientIAM}, sdkCertmgr: pcertmgr, }, nil } @@ -118,6 +141,12 @@ func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*Dep return nil, fmt.Errorf("config `listenerArn` is required") } + // 解析证书内容 + certX509, err := xcert.ParseCertificateFromPEM(certPEM) + if err != nil { + return nil, err + } + // 上传证书 upres, err := d.sdkCertmgr.Upload(ctx, certPEM, privkeyPEM) if err != nil { @@ -131,7 +160,7 @@ func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*Dep describeLoadBalancersReq := &elasticloadbalancingv2.DescribeLoadBalancersInput{ LoadBalancerArns: []string{d.config.LoadbalancerArn}, } - describeLoadBalancersResp, err := d.sdkClient.DescribeLoadBalancers(ctx, describeLoadBalancersReq) + describeLoadBalancersResp, err := d.sdkClients.ELB.DescribeLoadBalancers(ctx, describeLoadBalancersReq) d.logger.Debug("sdk request 'elasticloadbalancingv2.DescribeLoadBalancers'", slog.Any("request", describeLoadBalancersReq), slog.Any("response", describeLoadBalancersResp)) if err != nil { return nil, fmt.Errorf("failed to execute sdk request 'elasticloadbalancingv2.DescribeLoadBalancers': %w", err) @@ -145,7 +174,7 @@ func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*Dep LoadBalancerArn: aws.String(d.config.LoadbalancerArn), ListenerArns: []string{d.config.ListenerArn}, } - describeListenersResp, err := d.sdkClient.DescribeListeners(ctx, describeListenersReq) + describeListenersResp, err := d.sdkClients.ELB.DescribeListeners(ctx, describeListenersReq) d.logger.Debug("sdk request 'elasticloadbalancingv2.DescribeListeners'", slog.Any("request", describeListenersReq), slog.Any("response", describeListenersResp)) if err != nil { return nil, fmt.Errorf("failed to execute sdk request 'elasticloadbalancingv2.DescribeListeners': %w", err) @@ -182,6 +211,10 @@ func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*Dep if err := d.updateListenerSniCertificate(ctx, *listenerInfo.ListenerArn, certArn); err != nil { return nil, err } + + if err := d.pruneListenerSniCertificates(ctx, *listenerInfo.ListenerArn, certArn, certX509.DNSNames); err != nil { + return nil, err + } } return &DeployResult{}, nil @@ -198,7 +231,7 @@ func (d *Deployer) updateListenerDefaultCertificate(ctx context.Context, cloudLi }, }, } - modifyListenerResp, err := d.sdkClient.ModifyListener(ctx, modifyListenerReq) + modifyListenerResp, err := d.sdkClients.ELB.ModifyListener(ctx, modifyListenerReq) d.logger.Debug("sdk request 'elasticloadbalancingv2.ModifyListener'", slog.Any("request", modifyListenerReq), slog.Any("response", modifyListenerResp)) if err != nil { return fmt.Errorf("failed to execute sdk request 'elasticloadbalancingv2.ModifyListener': %w", err) @@ -218,7 +251,7 @@ func (d *Deployer) updateListenerSniCertificate(ctx context.Context, cloudListen }, }, } - addListenerCertificatesResp, err := d.sdkClient.AddListenerCertificates(ctx, addListenerCertificatesReq) + addListenerCertificatesResp, err := d.sdkClients.ELB.AddListenerCertificates(ctx, addListenerCertificatesReq) d.logger.Debug("sdk request 'elasticloadbalancingv2.AddListenerCertificates'", slog.Any("request", addListenerCertificatesReq), slog.Any("response", addListenerCertificatesResp)) if err != nil { return fmt.Errorf("failed to execute sdk request 'elasticloadbalancingv2.AddListenerCertificates': %w", err) @@ -227,7 +260,132 @@ func (d *Deployer) updateListenerSniCertificate(ctx context.Context, cloudListen return nil } -func createSDKClient(authMethod, accessKeyId, secretAccessKey, region string) (*elasticloadbalancingv2.Client, error) { +func (d *Deployer) pruneListenerSniCertificates(ctx context.Context, cloudListenerArn string, cloudCertArn string, cloudCertSANs []string) error { + const acmArnPrefix = "arn:aws:acm:" + const iamArnPrefix = "arn:aws:iam:" + + certArnsToDelete := make([]string, 0) + + // 查询侦听器证书列表,并找出需要删除绑定的同域名证书 + // REF: https://docs.aws.amazon.com/elasticloadbalancing/latest/APIReference/API_DescribeListenerCertificates.html + // REF: https://docs.aws.amazon.com/IAM/latest/APIReference/API_GetServerCertificate.html + // REF: https://docs.aws.amazon.com/acm/latest/APIReference/API_GetCertificate.html + describeListenerCertificatesMarker := (*string)(nil) + describeListenerCertificatesPageSize := 100 + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + describeListenerCertificatesReq := &elasticloadbalancingv2.DescribeListenerCertificatesInput{ + ListenerArn: aws.String(cloudListenerArn), + Marker: describeListenerCertificatesMarker, + PageSize: aws.Int32(int32(describeListenerCertificatesPageSize)), + } + describeListenerCertificatesResp, err := d.sdkClients.ELB.DescribeListenerCertificates(ctx, describeListenerCertificatesReq) + d.logger.Debug("sdk request 'elasticloadbalancingv2.DescribeListenerCertificates'", slog.Any("request", describeListenerCertificatesReq), slog.Any("response", describeListenerCertificatesResp)) + if err != nil { + return fmt.Errorf("failed to execute sdk request 'elasticloadbalancingv2.DescribeListenerCertificates': %w", err) + } + + for _, certItem := range describeListenerCertificatesResp.Certificates { + if aws.ToString(certItem.CertificateArn) == cloudCertArn { + continue + } + if aws.ToBool(certItem.IsDefault) { + continue + } + + switch { + case strings.HasPrefix(aws.ToString(certItem.CertificateArn), acmArnPrefix): + { + getCertificateReq := &acm.GetCertificateInput{ + CertificateArn: certItem.CertificateArn, + } + getCertificateResp, err := d.sdkClients.ACM.GetCertificate(ctx, getCertificateReq) + if err != nil { + var sdkerr smithy.APIError + if errors.As(err, &sdkerr) { + if sdkErrCode := sdkerr.ErrorCode(); sdkErrCode == "InvalidArnException" || sdkErrCode == "ResourceNotFoundException" { + continue + } + } + + return fmt.Errorf("failed to execute sdk request 'acm.GetCertificate': %w", err) + } + + certX509InACM, err := xcert.ParseCertificateFromPEM(aws.ToString(getCertificateResp.Certificate)) + if err != nil { + continue + } + + if lo.ElementsMatch(cloudCertSANs, certX509InACM.DNSNames) { + certArnsToDelete = append(certArnsToDelete, aws.ToString(certItem.CertificateArn)) + } + } + + case strings.HasPrefix(aws.ToString(certItem.CertificateArn), iamArnPrefix): + { + getServerCertificateReq := &iam.GetServerCertificateInput{ + ServerCertificateName: certItem.CertificateArn, + } + getServerCertificateResp, err := d.sdkClients.IAM.GetServerCertificate(ctx, getServerCertificateReq) + if err != nil { + var sdkerr smithy.APIError + if errors.As(err, &sdkerr) { + if sdkerr.ErrorCode() == "NoSuchEntity" { + continue + } + } + + return fmt.Errorf("failed to execute sdk request 'iam.GetServerCertificate': %w", err) + } + + certX509InIAM, err := xcert.ParseCertificateFromPEM(aws.ToString(getServerCertificateResp.ServerCertificate.CertificateBody)) + if err != nil { + continue + } + + if lo.ElementsMatch(cloudCertSANs, certX509InIAM.DNSNames) { + certArnsToDelete = append(certArnsToDelete, aws.ToString(certItem.CertificateArn)) + } + } + } + } + + if len(describeListenerCertificatesResp.Certificates) < describeListenerCertificatesPageSize || describeListenerCertificatesResp.NextMarker == nil { + break + } + + describeListenerCertificatesMarker = describeListenerCertificatesResp.NextMarker + } + + // 从证书列表删除证书 + // REF: https://docs.aws.amazon.com/elasticloadbalancing/latest/APIReference/API_RemoveListenerCertificates.html + if len(certArnsToDelete) > 0 { + d.logger.Info("found nlb listener certificates to delete", slog.Any("certificateArns", certArnsToDelete)) + + removeListenerCertificatesReq := &elasticloadbalancingv2.RemoveListenerCertificatesInput{ + ListenerArn: aws.String(d.config.ListenerArn), + Certificates: lo.Map(certArnsToDelete, func(certArn string, _ int) types.Certificate { + return types.Certificate{ + CertificateArn: aws.String(certArn), + } + }), + } + removeListenerCertificatesResp, err := d.sdkClients.ELB.RemoveListenerCertificates(ctx, removeListenerCertificatesReq) + d.logger.Debug("sdk request 'elasticloadbalancingv2.RemoveListenerCertificates'", slog.Any("request", removeListenerCertificatesReq), slog.Any("response", removeListenerCertificatesResp)) + if err != nil { + return fmt.Errorf("failed to execute sdk request 'elasticloadbalancingv2.RemoveListenerCertificates': %w", err) + } + } + + return nil +} + +func createSDKClientELB(authMethod, accessKeyId, secretAccessKey, region string) (*elasticloadbalancingv2.Client, error) { opts := []func(options *awscfg.LoadOptions) error{ awscfg.WithRegion(region), } @@ -255,3 +413,61 @@ func createSDKClient(authMethod, accessKeyId, secretAccessKey, region string) (* client := elasticloadbalancingv2.NewFromConfig(cfg) return client, nil } + +func createSDKClientACM(authMethod, accessKeyId, secretAccessKey, region string) (*acm.Client, error) { + opts := []func(options *awscfg.LoadOptions) error{ + awscfg.WithRegion(region), + } + + staticCredsProvider := awscred.NewStaticCredentialsProvider(accessKeyId, secretAccessKey, "") + imdsCredsProvider := aws.NewCredentialsCache(ec2rolecreds.New()) + switch authMethod { + case "": + if accessKeyId != "" && secretAccessKey != "" { + opts = append(opts, awscfg.WithCredentialsProvider(staticCredsProvider)) + } + case AUTH_METHOD_ACCESSKEY: + opts = append(opts, awscfg.WithCredentialsProvider(staticCredsProvider)) + case AUTH_METHOD_IMDS: + opts = append(opts, awscfg.WithCredentialsProvider(imdsCredsProvider)) + default: + return nil, fmt.Errorf("unsupported auth method '%s'", authMethod) + } + + cfg, err := awscfg.LoadDefaultConfig(context.Background(), opts...) + if err != nil { + return nil, err + } + + client := acm.NewFromConfig(cfg) + return client, nil +} + +func createSDKClientIAM(authMethod, accessKeyId, secretAccessKey, region string) (*iam.Client, error) { + opts := []func(options *awscfg.LoadOptions) error{ + awscfg.WithRegion(region), + } + + staticCredsProvider := awscred.NewStaticCredentialsProvider(accessKeyId, secretAccessKey, "") + imdsCredsProvider := aws.NewCredentialsCache(ec2rolecreds.New()) + switch authMethod { + case "": + if accessKeyId != "" && secretAccessKey != "" { + opts = append(opts, awscfg.WithCredentialsProvider(staticCredsProvider)) + } + case AUTH_METHOD_ACCESSKEY: + opts = append(opts, awscfg.WithCredentialsProvider(staticCredsProvider)) + case AUTH_METHOD_IMDS: + opts = append(opts, awscfg.WithCredentialsProvider(imdsCredsProvider)) + default: + return nil, fmt.Errorf("unsupported auth method '%s'", authMethod) + } + + cfg, err := awscfg.LoadDefaultConfig(context.Background(), opts...) + if err != nil { + return nil, err + } + + client := iam.NewFromConfig(cfg) + return client, nil +} diff --git a/pkg/core/purger/providers-tester/aws-acm/aws_acm_test.go b/pkg/core/purger/providers-tester/aws-acm/aws_acm_test.go new file mode 100644 index 000000000..74449f905 --- /dev/null +++ b/pkg/core/purger/providers-tester/aws-acm/aws_acm_test.go @@ -0,0 +1,48 @@ +//go:build tester + +package awsacm_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + tester "github.com/certimate-go/certimate/pkg/core/purger/providers-tester" + impl "github.com/certimate-go/certimate/pkg/core/purger/providers/aws-acm" +) + +var ( + fp = tester.InitArgs("AWSACM_") + fAccessKeyId string + fSecretAccessKey string + fRegion string +) + +func init() { + fp.DefineString(&fAccessKeyId, "ACCESSKEYID") + fp.DefineString(&fSecretAccessKey, "SECRETACCESSKEY") + fp.DefineString(&fRegion, "REGION") +} + +/* +Shell command to run this test: + + go test -tags=tester -v ./aws_acm_test.go -args \ + --AWSACM_ACCESSKEYID="your-access-key-id" \ + --AWSACM_SECRETACCESSKEY="your-access-key-secret" \ + --AWSACM_REGION="us-east-1" +*/ +func TestProvider(t *testing.T) { + fp.Parse() + + t.Run("Purge", func(t *testing.T) { + provider, err := impl.NewPurger(&impl.PurgerConfig{ + AccessKeyId: fAccessKeyId, + SecretAccessKey: fSecretAccessKey, + Region: fRegion, + }) + require.NoError(t, err) + + tester.Purge(t, provider, tester.PurgeInput{}) + }) +} diff --git a/pkg/core/purger/providers/aws-acm/aws_acm.go b/pkg/core/purger/providers/aws-acm/aws_acm.go new file mode 100644 index 000000000..5aa84197f --- /dev/null +++ b/pkg/core/purger/providers/aws-acm/aws_acm.go @@ -0,0 +1,187 @@ +package awsacm + +import ( + "context" + "errors" + "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" + "github.com/aws/aws-sdk-go-v2/credentials/ec2rolecreds" + "github.com/aws/aws-sdk-go-v2/service/acm" + "github.com/aws/aws-sdk-go-v2/service/acm/types" + "github.com/aws/smithy-go" + "github.com/samber/lo" + + "github.com/certimate-go/certimate/pkg/core" + xloop "github.com/certimate-go/certimate/pkg/utils/loop" + xwait "github.com/certimate-go/certimate/pkg/utils/wait" +) + +type ( + Provider = core.Purger + PurgeResult = core.PurgerPurgeResult +) + +type PurgerConfig struct { + // AWS API 认证方式。 + // 可取值 "accesskey"、"imds"。 + // 零值时默认值 [AUTH_METHOD_ACCESSKEY]。 + AuthMethod string `json:"authMethod,omitempty"` + // AWS AccessKeyId。 + AccessKeyId string `json:"accessKeyId"` + // AWS SecretAccessKey。 + SecretAccessKey string `json:"secretAccessKey"` + // AWS 区域。 + Region string `json:"region"` +} + +type Purger struct { + config *PurgerConfig + logger *slog.Logger + sdkClient *acm.Client +} + +var _ Provider = (*Purger)(nil) + +func NewPurger(config *PurgerConfig) (*Purger, error) { + if config == nil { + return nil, fmt.Errorf("the configuration of the purger provider is nil") + } + + client, err := createSDKClient(config.AuthMethod, config.AccessKeyId, config.SecretAccessKey, config.Region) + if err != nil { + return nil, fmt.Errorf("could not create client: %w", err) + } + + return &Purger{ + config: config, + logger: slog.Default(), + sdkClient: client, + }, nil +} + +func (p *Purger) SetLogger(logger *slog.Logger) { + if logger == nil { + p.logger = slog.New(slog.DiscardHandler) + } else { + p.logger = logger + } +} + +func (p *Purger) Purge(ctx context.Context, expiry time.Duration) (*PurgeResult, error) { + purgingCertArns := make([]string, 0) + + // 获取证书列表,避免重复上传 + // REF: https://docs.aws.amazon.com/acm/latest/APIReference/API_ListCertificates.html + listCertificatesNextToken := (*string)(nil) + for { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + listCertificatesReq := &acm.ListCertificatesInput{ + NextToken: listCertificatesNextToken, + MaxItems: aws.Int32(1000), + } + listCertificatesResp, err := p.sdkClient.ListCertificates(ctx, listCertificatesReq) + p.logger.Debug("sdk request 'acm.ListCertificates'", slog.Any("request", listCertificatesReq), slog.Any("response", listCertificatesResp)) + if err != nil { + return nil, fmt.Errorf("failed to execute sdk request 'acm.ListCertificates': %w", err) + } + + for _, certItem := range listCertificatesResp.CertificateSummaryList { + if certItem.CertificateKeyPairOrigin == types.CertificateKeyPairOriginAcme { + continue + } + if certItem.NotAfter == nil { + continue + } + + certNotAfter := lo.FromPtr(certItem.NotAfter) + if time.Since(certNotAfter) >= expiry { + purgingCertArns = append(purgingCertArns, *certItem.CertificateArn) + } + } + + if len(listCertificatesResp.CertificateSummaryList) == 0 || listCertificatesResp.NextToken == nil { + break + } + + listCertificatesNextToken = listCertificatesResp.NextToken + } + + // 删除服务器证书 + // REF: https://docs.aws.amazon.com/acm/latest/APIReference/API_DeleteCertificate.html + purgedCount := 0 + if len(purgingCertArns) > 0 { + p.logger.Info("found certificates to purge", slog.Any("certArns", purgingCertArns)) + + if err := xloop.ForRangeAllWithContext(ctx, purgingCertArns, func(ctx context.Context, certArn string, i int) error { + if i > 0 { + if err := xwait.DelayWithContext(ctx, 1*time.Second); err != nil { + return err + } + } + + deleteServerCertificateReq := &acm.DeleteCertificateInput{ + CertificateArn: aws.String(certArn), + } + deleteServerCertificateResp, err := p.sdkClient.DeleteCertificate(ctx, deleteServerCertificateReq) + p.logger.Debug("sdk request 'acm.DeleteCertificate'", slog.Any("request", deleteServerCertificateReq), slog.Any("response", deleteServerCertificateResp)) + if err != nil { + var sdkErr smithy.APIError + if errors.As(err, &sdkErr) { + if sdkErrCode := sdkErr.ErrorCode(); sdkErrCode == "ResourceNotFoundException" || sdkErrCode == "ResourceInUseException" { + return nil + } + } + + return fmt.Errorf("failed to execute sdk request 'acm.DeleteCertificate': %w", err) + } + + purgedCount++ + return nil + }); err != nil { + return nil, err + } + } + + return &PurgeResult{ + Count: purgedCount, + }, nil +} + +func createSDKClient(authMethod, accessKeyId, secretAccessKey, region string) (*acm.Client, error) { + opts := []func(options *awscfg.LoadOptions) error{ + awscfg.WithRegion(region), + } + + staticCredsProvider := awscred.NewStaticCredentialsProvider(accessKeyId, secretAccessKey, "") + imdsCredsProvider := aws.NewCredentialsCache(ec2rolecreds.New()) + switch authMethod { + case "": + if accessKeyId != "" && secretAccessKey != "" { + opts = append(opts, awscfg.WithCredentialsProvider(staticCredsProvider)) + } + case AUTH_METHOD_ACCESSKEY: + opts = append(opts, awscfg.WithCredentialsProvider(staticCredsProvider)) + case AUTH_METHOD_IMDS: + opts = append(opts, awscfg.WithCredentialsProvider(imdsCredsProvider)) + default: + return nil, fmt.Errorf("unsupported auth method '%s'", authMethod) + } + + cfg, err := awscfg.LoadDefaultConfig(context.Background(), opts...) + if err != nil { + return nil, err + } + + client := acm.NewFromConfig(cfg) + return client, nil +} diff --git a/pkg/core/purger/providers/aws-acm/consts.go b/pkg/core/purger/providers/aws-acm/consts.go new file mode 100644 index 000000000..f9528cd2d --- /dev/null +++ b/pkg/core/purger/providers/aws-acm/consts.go @@ -0,0 +1,10 @@ +package awsacm + +import ( + iam "github.com/certimate-go/certimate/pkg/core/certmgr/providers/aws-iam" +) + +const ( + AUTH_METHOD_ACCESSKEY = iam.AUTH_METHOD_ACCESSKEY + AUTH_METHOD_IMDS = iam.AUTH_METHOD_IMDS +) diff --git a/ui/src/components/workflow/designer/forms/BizPurgeNodeConfigFieldsProvider.tsx b/ui/src/components/workflow/designer/forms/BizPurgeNodeConfigFieldsProvider.tsx index e45874dc9..e20a69652 100644 --- a/ui/src/components/workflow/designer/forms/BizPurgeNodeConfigFieldsProvider.tsx +++ b/ui/src/components/workflow/designer/forms/BizPurgeNodeConfigFieldsProvider.tsx @@ -5,6 +5,7 @@ import { PURGE_PROVIDERS, type PurgeProviderType } from "@/domain/provider"; import BizPurgeNodeConfigFieldsProviderAliyunCAS from "./BizPurgeNodeConfigFieldsProviderAliyunCAS"; import BizPurgeNodeConfigFieldsProviderAliyunCLB from "./BizPurgeNodeConfigFieldsProviderAliyunCLB"; import BizPurgeNodeConfigFieldsProviderAliyunESA from "./BizPurgeNodeConfigFieldsProviderAliyunESA"; +import BizPurgeNodeConfigFieldsProviderAWSACM from "./BizPurgeNodeConfigFieldsProviderAWSACM"; import BizPurgeNodeConfigFieldsProviderAWSIAM from "./BizPurgeNodeConfigFieldsProviderAWSIAM"; import BizPurgeNodeConfigFieldsProviderAzureKeyVault from "./BizPurgeNodeConfigFieldsProviderAzureKeyVault"; import BizPurgeNodeConfigFieldsProviderHuaweiCloudELB from "./BizPurgeNodeConfigFieldsProviderHuaweiCloudELB"; @@ -23,6 +24,7 @@ const providerComponentMap: Partial { + const { i18n, t } = useTranslation(); + + const { parentNamePath } = useFormNestedFieldsContext(); + const formSchema = z.object({ + [parentNamePath]: getSchema({ i18n }), + }); + const formRule = createSchemaFieldRule(formSchema); + const initialValues = getInitialValues(); + + return ( + <> + } + > + + + + ); +}; + +const getInitialValues = (): Nullish>> => { + return { + region: "", + }; +}; + +const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType }) => { + const { t: _ } = i18n; + + return z.object({ + region: z.string().nonempty(), + }); +}; + +const _default = Object.assign(BizPurgeNodeConfigFieldsProviderAWSACM, { + getInitialValues, + getSchema, +}); + +export default _default; diff --git a/ui/src/domain/provider.ts b/ui/src/domain/provider.ts index 05691e7f9..dc9c79ae4 100644 --- a/ui/src/domain/provider.ts +++ b/ui/src/domain/provider.ts @@ -989,6 +989,7 @@ export const PURGE_PROVIDERS = Object.freeze( ALIYUN_CAS: `${ACCESS_PROVIDERS.ALIYUN}-cas`, ALIYUN_CLB: `${ACCESS_PROVIDERS.ALIYUN}-clb`, ALIYUN_ESA: `${ACCESS_PROVIDERS.ALIYUN}-esa`, + AWS_ACM: `${ACCESS_PROVIDERS.AWS}-acm`, AWS_IAM: `${ACCESS_PROVIDERS.AWS}-iam`, AZURE_KEYVAULT: `${ACCESS_PROVIDERS.AZURE}-keyvault`, BAIDUCLOUD_CERT: `${ACCESS_PROVIDERS.BAIDUCLOUD}-cert`, @@ -1025,6 +1026,7 @@ export const purgeProvidersMap: Maphttps://esa.console.aliyun.com/siteManage/list" }, + "aws_acm_region": { + "label": "AWS Region", + "placeholder": "Please enter AWS ACM region (e.g. us-east-1)", + "tooltip": "For more information, see https://docs.aws.amazon.com/en_us/general/latest/gr/rande.html#regional-endpoints" + }, "aws_iam_region": { "label": "AWS Region", "placeholder": "Please enter AWS IAM region (e.g. us-east-1)", diff --git a/ui/src/i18n/resources/zh/nls.workflow.nodes.json b/ui/src/i18n/resources/zh/nls.workflow.nodes.json index d9438b374..92ac63f24 100644 --- a/ui/src/i18n/resources/zh/nls.workflow.nodes.json +++ b/ui/src/i18n/resources/zh/nls.workflow.nodes.json @@ -3051,6 +3051,11 @@ "placeholder": "请输入阿里云 ESA 站点 ID", "tooltip": "这是什么?请参阅 https://esa.console.aliyun.com/siteManage/list" }, + "aws_acm_region": { + "label": "AWS 服务区域", + "placeholder": "请输入 AWS ACM 服务区域(例如:us-east-1)", + "tooltip": "这是什么?请参阅 https://docs.aws.amazon.com/zh_cn/general/latest/gr/rande.html#regional-endpoints" + }, "aws_iam_region": { "label": "AWS 服务区域", "placeholder": "请输入 AWS IAM 服务区域(例如:us-east-1)",