From 3d94f30f5d33e332b39af98c2fb26bb8bea29010 Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Fri, 17 Apr 2026 11:46:06 +0800 Subject: [PATCH] feat(provider): new deployment provider: ftp --- internal/certmgmt/deployers/sp_ftp.go | 36 +++ internal/domain/provider.go | 1 + .../certifier/challengers/http01/ssh/ssh.go | 2 +- pkg/core/deployer/providers/ftp/consts.go | 11 + pkg/core/deployer/providers/ftp/ftp.go | 213 ++++++++++++ pkg/core/deployer/providers/ftp/ftp_test.go | 89 ++++++ pkg/core/deployer/providers/local/local.go | 36 ++- pkg/core/deployer/providers/s3/s3.go | 36 ++- pkg/core/deployer/providers/ssh/ssh.go | 39 ++- .../BizDeployNodeConfigFieldsProvider.tsx | 2 + .../BizDeployNodeConfigFieldsProviderFTP.tsx | 302 ++++++++++++++++++ ui/src/domain/provider.ts | 2 + .../i18n/locales/en/nls.workflow.nodes.json | 31 ++ .../i18n/locales/zh/nls.workflow.nodes.json | 31 ++ 14 files changed, 787 insertions(+), 44 deletions(-) create mode 100644 internal/certmgmt/deployers/sp_ftp.go create mode 100644 pkg/core/deployer/providers/ftp/consts.go create mode 100644 pkg/core/deployer/providers/ftp/ftp.go create mode 100644 pkg/core/deployer/providers/ftp/ftp_test.go create mode 100644 ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderFTP.tsx diff --git a/internal/certmgmt/deployers/sp_ftp.go b/internal/certmgmt/deployers/sp_ftp.go new file mode 100644 index 000000000..31da4c3d5 --- /dev/null +++ b/internal/certmgmt/deployers/sp_ftp.go @@ -0,0 +1,36 @@ +package deployers + +import ( + "fmt" + + "github.com/certimate-go/certimate/internal/domain" + "github.com/certimate-go/certimate/pkg/core/deployer" + "github.com/certimate-go/certimate/pkg/core/deployer/providers/ftp" + xmaps "github.com/certimate-go/certimate/pkg/utils/maps" +) + +func init() { + Registries.MustRegister(domain.DeploymentProviderTypeFTP, func(options *ProviderFactoryOptions) (deployer.Provider, error) { + credentials := domain.AccessConfigForFTP{} + if err := xmaps.Populate(options.ProviderAccessConfig, &credentials); err != nil { + return nil, fmt.Errorf("failed to populate provider access config: %w", err) + } + + provider, err := ftp.NewDeployer(&ftp.DeployerConfig{ + FtpHost: credentials.Host, + FtpPort: credentials.Port, + FtpUsername: credentials.Username, + FtpPassword: credentials.Password, + OutputFormat: xmaps.GetOrDefaultString(options.ProviderExtendedConfig, "format", ftp.OUTPUT_FORMAT_PEM), + OutputKeyPath: xmaps.GetString(options.ProviderExtendedConfig, "keyPath"), + OutputCertPath: xmaps.GetString(options.ProviderExtendedConfig, "certPath"), + OutputServerCertPath: xmaps.GetString(options.ProviderExtendedConfig, "certPathForServerOnly"), + OutputIntermediaCertPath: xmaps.GetString(options.ProviderExtendedConfig, "certPathForIntermediaOnly"), + PfxPassword: xmaps.GetString(options.ProviderExtendedConfig, "pfxPassword"), + JksAlias: xmaps.GetString(options.ProviderExtendedConfig, "jksAlias"), + JksKeypass: xmaps.GetString(options.ProviderExtendedConfig, "jksKeypass"), + JksStorepass: xmaps.GetString(options.ProviderExtendedConfig, "jksStorepass"), + }) + return provider, err + }) +} diff --git a/internal/domain/provider.go b/internal/domain/provider.go index fdff9da0f..637f06754 100644 --- a/internal/domain/provider.go +++ b/internal/domain/provider.go @@ -313,6 +313,7 @@ const ( DeploymentProviderTypeDokploy = DeploymentProviderType(AccessProviderTypeDokploy) DeploymentProviderTypeFlexCDN = DeploymentProviderType(AccessProviderTypeFlexCDN) DeploymentProviderTypeFlyIO = DeploymentProviderType(AccessProviderTypeFlyIO) + DeploymentProviderTypeFTP = DeploymentProviderType(AccessProviderTypeFTP) DeploymentProviderTypeGcoreCDN = DeploymentProviderType(AccessProviderTypeGcore + "-cdn") DeploymentProviderTypeGoEdge = DeploymentProviderType(AccessProviderTypeGoEdge) DeploymentProviderTypeHuaweiCloudAAD = DeploymentProviderType(AccessProviderTypeHuaweiCloud + "-aad") diff --git a/pkg/core/certifier/challengers/http01/ssh/ssh.go b/pkg/core/certifier/challengers/http01/ssh/ssh.go index ba69d6c80..1b4bf3233 100644 --- a/pkg/core/certifier/challengers/http01/ssh/ssh.go +++ b/pkg/core/certifier/challengers/http01/ssh/ssh.go @@ -14,7 +14,7 @@ import ( type ServerConfig struct { // SSH 主机。 - SshHost string `json:"sshHost,omitempty"` + SshHost string `json:"sshHost"` // SSH 端口。 // 零值时默认值 22。 SshPort int32 `json:"sshPort,omitempty"` diff --git a/pkg/core/deployer/providers/ftp/consts.go b/pkg/core/deployer/providers/ftp/consts.go new file mode 100644 index 000000000..5c529b343 --- /dev/null +++ b/pkg/core/deployer/providers/ftp/consts.go @@ -0,0 +1,11 @@ +package ftp + +import ( + "github.com/certimate-go/certimate/internal/domain" +) + +const ( + OUTPUT_FORMAT_PEM = string(domain.CertificateFormatTypePEM) + OUTPUT_FORMAT_PFX = string(domain.CertificateFormatTypePFX) + OUTPUT_FORMAT_JKS = string(domain.CertificateFormatTypeJKS) +) diff --git a/pkg/core/deployer/providers/ftp/ftp.go b/pkg/core/deployer/providers/ftp/ftp.go new file mode 100644 index 000000000..bde793f39 --- /dev/null +++ b/pkg/core/deployer/providers/ftp/ftp.go @@ -0,0 +1,213 @@ +package ftp + +import ( + "context" + "errors" + "fmt" + "log/slog" + "path/filepath" + + "github.com/certimate-go/certimate/internal/tools/ftp" + "github.com/certimate-go/certimate/pkg/core/deployer" + xcert "github.com/certimate-go/certimate/pkg/utils/cert" +) + +type DeployerConfig struct { + // FTP 主机。 + FtpHost string `json:"ftpHost"` + // FTP 端口。 + // 零值时默认值 21。 + FtpPort int32 `json:"ftpPort,omitempty"` + // FTP 登录用户名。 + FtpUsername string `json:"ftpUsername,omitempty"` + // FTP 登录密码。 + FtpPassword string `json:"ftpPassword,omitempty"` + // 输出证书格式。 + OutputFormat string `json:"outputFormat,omitempty"` + // 输出私钥文件路径。 + OutputKeyPath string `json:"outputKeyPath,omitempty"` + // 输出证书文件路径。 + OutputCertPath string `json:"outputCertPath,omitempty"` + // 输出服务器证书文件路径。 + // 选填。 + OutputServerCertPath string `json:"outputServerCertPath,omitempty"` + // 输出中间证书文件路径。 + // 选填。 + OutputIntermediaCertPath string `json:"outputIntermediaCertPath,omitempty"` + // PFX 导出密码。 + // 证书格式为 PFX 时必填。 + PfxPassword string `json:"pfxPassword,omitempty"` + // JKS 别名。 + // 证书格式为 JKS 时必填。 + JksAlias string `json:"jksAlias,omitempty"` + // JKS 密钥密码。 + // 证书格式为 JKS 时必填。 + JksKeypass string `json:"jksKeypass,omitempty"` + // JKS 存储密码。 + // 证书格式为 JKS 时必填。 + JksStorepass string `json:"jksStorepass,omitempty"` +} + +type Deployer struct { + config *DeployerConfig + logger *slog.Logger +} + +var _ deployer.Provider = (*Deployer)(nil) + +func NewDeployer(config *DeployerConfig) (*Deployer, error) { + if config == nil { + return nil, errors.New("the configuration of the deployer provider is nil") + } + + return &Deployer{ + config: config, + logger: slog.Default(), + }, nil +} + +func (d *Deployer) SetLogger(logger *slog.Logger) { + if logger == nil { + d.logger = slog.New(slog.DiscardHandler) + } else { + d.logger = logger + } +} + +func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*deployer.DeployResult, error) { + // 提取服务器证书和中间证书 + serverCertPEM, intermediaCertPEM, err := xcert.ExtractCertificatesFromPEM(certPEM) + if err != nil { + return nil, fmt.Errorf("failed to extract certs: %w", err) + } + + client, err := createFtpClient(*d.config) + if err != nil { + return nil, fmt.Errorf("ftp: failed to create FTP client: %w", err) + } + + d.logger.Info("ftp connected") + defer client.Quit(context.Background()) + + // 上传证书和私钥文件 + switch d.config.OutputFormat { + case OUTPUT_FORMAT_PEM: + { + if d.config.OutputKeyPath != "" { + if err := client.MkdirAll(ctx, filepath.Dir(d.config.OutputKeyPath)); err != nil { + return nil, fmt.Errorf("failed to upload private key file: %w", err) + } + if err := client.ChangeDir(ctx, filepath.Dir(d.config.OutputKeyPath)); err != nil { + return nil, fmt.Errorf("failed to upload private key file: %w", err) + } + if err := client.StoreString(ctx, filepath.Base(d.config.OutputKeyPath), privkeyPEM); err != nil { + return nil, fmt.Errorf("failed to upload private key file: %w", err) + } + d.logger.Info("ssl private key file uploaded", slog.String("path", d.config.OutputKeyPath)) + } + + if d.config.OutputCertPath != "" { + if err := client.MkdirAll(ctx, filepath.Dir(d.config.OutputCertPath)); err != nil { + return nil, fmt.Errorf("failed to upload certificate file: %w", err) + } + if err := client.ChangeDir(ctx, filepath.Dir(d.config.OutputCertPath)); err != nil { + return nil, fmt.Errorf("failed to upload certificate file: %w", err) + } + if err := client.StoreString(ctx, filepath.Base(d.config.OutputCertPath), certPEM); err != nil { + return nil, fmt.Errorf("failed to upload certificate file: %w", err) + } + d.logger.Info("ssl certificate file uploaded", slog.String("path", d.config.OutputCertPath)) + } + + if d.config.OutputServerCertPath != "" { + if err := client.MkdirAll(ctx, filepath.Dir(d.config.OutputServerCertPath)); err != nil { + return nil, fmt.Errorf("failed to upload server certificate file: %w", err) + } + if err := client.ChangeDir(ctx, filepath.Dir(d.config.OutputServerCertPath)); err != nil { + return nil, fmt.Errorf("failed to upload server certificate file: %w", err) + } + if err := client.StoreString(ctx, filepath.Base(d.config.OutputServerCertPath), serverCertPEM); err != nil { + return nil, fmt.Errorf("failed to upload server certificate file: %w", err) + } + d.logger.Info("ssl server certificate file uploaded", slog.String("path", d.config.OutputServerCertPath)) + } + + if d.config.OutputIntermediaCertPath != "" { + if err := client.MkdirAll(ctx, filepath.Dir(d.config.OutputIntermediaCertPath)); err != nil { + return nil, fmt.Errorf("failed to upload intermedia certificate file: %w", err) + } + if err := client.ChangeDir(ctx, filepath.Dir(d.config.OutputIntermediaCertPath)); err != nil { + return nil, fmt.Errorf("failed to upload intermedia certificate file: %w", err) + } + if err := client.StoreString(ctx, filepath.Base(d.config.OutputIntermediaCertPath), intermediaCertPEM); err != nil { + return nil, fmt.Errorf("failed to upload intermedia certificate file: %w", err) + } + d.logger.Info("ssl intermedia certificate file uploaded", slog.String("path", d.config.OutputIntermediaCertPath)) + } + } + + case OUTPUT_FORMAT_PFX: + { + pfxData, err := xcert.TransformCertificateFromPEMToPFX(certPEM, privkeyPEM, d.config.PfxPassword) + if err != nil { + return nil, fmt.Errorf("failed to transform certificate to PFX: %w", err) + } + d.logger.Info("ssl certificate transformed to pfx") + + if d.config.OutputCertPath != "" { + if err := client.MkdirAll(ctx, filepath.Dir(d.config.OutputCertPath)); err != nil { + return nil, fmt.Errorf("failed to upload certificate file: %w", err) + } + if err := client.ChangeDir(ctx, filepath.Dir(d.config.OutputCertPath)); err != nil { + return nil, fmt.Errorf("failed to upload certificate file: %w", err) + } + if err := client.StoreBytes(ctx, filepath.Base(d.config.OutputCertPath), pfxData); err != nil { + return nil, fmt.Errorf("failed to upload certificate file: %w", err) + } + d.logger.Info("ssl certificate file uploaded", slog.String("path", d.config.OutputCertPath)) + } + } + + case OUTPUT_FORMAT_JKS: + { + jksData, err := xcert.TransformCertificateFromPEMToJKS(certPEM, privkeyPEM, d.config.JksAlias, d.config.JksKeypass, d.config.JksStorepass) + if err != nil { + return nil, fmt.Errorf("failed to transform certificate to JKS: %w", err) + } + d.logger.Info("ssl certificate transformed to jks") + + if d.config.OutputCertPath != "" { + if err := client.MkdirAll(ctx, filepath.Dir(d.config.OutputCertPath)); err != nil { + return nil, fmt.Errorf("failed to upload certificate file: %w", err) + } + if err := client.ChangeDir(ctx, filepath.Dir(d.config.OutputCertPath)); err != nil { + return nil, fmt.Errorf("failed to upload certificate file: %w", err) + } + if err := client.StoreBytes(ctx, filepath.Base(d.config.OutputCertPath), jksData); err != nil { + return nil, fmt.Errorf("failed to upload certificate file: %w", err) + } + d.logger.Info("ssl certificate file uploaded", slog.String("path", d.config.OutputCertPath)) + } + } + + default: + return nil, fmt.Errorf("unsupported output format '%s'", d.config.OutputFormat) + } + + return &deployer.DeployResult{}, nil +} + +func createFtpClient(config DeployerConfig) (*ftp.Client, error) { + clientCfg := ftp.NewDefaultConfig() + clientCfg.Host = config.FtpHost + clientCfg.Port = int(config.FtpPort) + clientCfg.Username = config.FtpUsername + clientCfg.Password = config.FtpPassword + + client, err := ftp.NewClient(clientCfg) + if err != nil { + return nil, err + } + + return client, nil +} diff --git a/pkg/core/deployer/providers/ftp/ftp_test.go b/pkg/core/deployer/providers/ftp/ftp_test.go new file mode 100644 index 000000000..c96871d38 --- /dev/null +++ b/pkg/core/deployer/providers/ftp/ftp_test.go @@ -0,0 +1,89 @@ +package ftp_test + +import ( + "context" + "flag" + "fmt" + "os" + "strings" + "testing" + + provider "github.com/certimate-go/certimate/pkg/core/deployer/providers/ftp" +) + +var ( + fInputCertPath string + fInputKeyPath string + fFtpHost string + fFtpPort int64 + fFtpUsername string + fFtpPassword string + fOutputCertPath string + fOutputKeyPath string +) + +func init() { + argsPrefix := "FTP_" + + flag.StringVar(&fInputCertPath, argsPrefix+"INPUTCERTPATH", "", "") + flag.StringVar(&fInputKeyPath, argsPrefix+"INPUTKEYPATH", "", "") + flag.StringVar(&fFtpHost, argsPrefix+"FTPHOST", "", "") + flag.Int64Var(&fFtpPort, argsPrefix+"FTPPORT", 0, "") + flag.StringVar(&fFtpUsername, argsPrefix+"FTPUSERNAME", "", "") + flag.StringVar(&fFtpPassword, argsPrefix+"FTPPASSWORD", "", "") + flag.StringVar(&fOutputCertPath, argsPrefix+"OUTPUTCERTPATH", "", "") + flag.StringVar(&fOutputKeyPath, argsPrefix+"OUTPUTKEYPATH", "", "") +} + +/* +Shell command to run this test: + + go test -v ./ftp_test.go -args \ + --FTP_INPUTCERTPATH="/path/to/your-input-cert.pem" \ + --FTP_INPUTKEYPATH="/path/to/your-input-key.pem" \ + --FTP_FTPHOST="localhost" \ + --FTP_FTPPORT=21 \ + --FTP_FTPUSERNAME="USER" \ + --FTP_FTPPASSWORD="PASS" \ + --FTP_OUTPUTCERTPATH="/path/to/your-output-cert.pem" \ + --FTP_OUTPUTKEYPATH="/path/to/your-output-key.pem" +*/ +func TestDeploy(t *testing.T) { + flag.Parse() + + t.Run("Deploy", func(t *testing.T) { + t.Log(strings.Join([]string{ + "args:", + fmt.Sprintf("INPUTCERTPATH: %v", fInputCertPath), + fmt.Sprintf("INPUTKEYPATH: %v", fInputKeyPath), + fmt.Sprintf("FTPHOST: %v", fFtpHost), + fmt.Sprintf("FTPPORT: %v", fFtpPort), + fmt.Sprintf("FTPUSERNAME: %v", fFtpUsername), + fmt.Sprintf("FTPPASSWORD: %v", fFtpPassword), + fmt.Sprintf("OUTPUTCERTPATH: %v", fOutputCertPath), + fmt.Sprintf("OUTPUTKEYPATH: %v", fOutputKeyPath), + }, "\n")) + + provider, err := provider.NewDeployer(&provider.DeployerConfig{ + FtpHost: fFtpHost, + FtpPort: int32(fFtpPort), + FtpUsername: fFtpUsername, + FtpPassword: fFtpPassword, + OutputFormat: provider.OUTPUT_FORMAT_PEM, + OutputCertPath: fOutputCertPath, + OutputKeyPath: fOutputKeyPath, + }) + if err != nil { + t.Errorf("err: %+v", err) + } + + fInputCertData, _ := os.ReadFile(fInputCertPath) + fInputKeyData, _ := os.ReadFile(fInputKeyPath) + res, err := provider.Deploy(context.Background(), string(fInputCertData), string(fInputKeyData)) + if err != nil { + t.Errorf("err: %+v", err) + } + + t.Logf("ok: %v", res) + }) +} diff --git a/pkg/core/deployer/providers/local/local.go b/pkg/core/deployer/providers/local/local.go index 0ce645358..ab1815aca 100644 --- a/pkg/core/deployer/providers/local/local.go +++ b/pkg/core/deployer/providers/local/local.go @@ -105,10 +105,19 @@ func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*dep switch d.config.OutputFormat { case OUTPUT_FORMAT_PEM: { - if err := xfile.WriteString(d.config.OutputCertPath, certPEM); err != nil { - return nil, fmt.Errorf("failed to save certificate file: %w", err) + if d.config.OutputKeyPath != "" { + if err := xfile.WriteString(d.config.OutputKeyPath, privkeyPEM); err != nil { + return nil, fmt.Errorf("failed to save private key file: %w", err) + } + d.logger.Info("ssl private key file saved", slog.String("path", d.config.OutputKeyPath)) + } + + if d.config.OutputCertPath != "" { + if err := xfile.WriteString(d.config.OutputCertPath, certPEM); err != nil { + return nil, fmt.Errorf("failed to save certificate file: %w", err) + } + d.logger.Info("ssl certificate file saved", slog.String("path", d.config.OutputCertPath)) } - d.logger.Info("ssl certificate file saved", slog.String("path", d.config.OutputCertPath)) if d.config.OutputServerCertPath != "" { if err := xfile.WriteString(d.config.OutputServerCertPath, serverCertPEM); err != nil { @@ -123,11 +132,6 @@ func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*dep } d.logger.Info("ssl intermedia certificate file saved", slog.String("path", d.config.OutputIntermediaCertPath)) } - - if err := xfile.WriteString(d.config.OutputKeyPath, privkeyPEM); err != nil { - return nil, fmt.Errorf("failed to save private key file: %w", err) - } - d.logger.Info("ssl private key file saved", slog.String("path", d.config.OutputKeyPath)) } case OUTPUT_FORMAT_PFX: @@ -138,10 +142,12 @@ func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*dep } d.logger.Info("ssl certificate transformed to pfx") - if err := xfile.Write(d.config.OutputCertPath, pfxData); err != nil { - return nil, fmt.Errorf("failed to save certificate file: %w", err) + if d.config.OutputCertPath != "" { + if err := xfile.Write(d.config.OutputCertPath, pfxData); err != nil { + return nil, fmt.Errorf("failed to save certificate file: %w", err) + } + d.logger.Info("ssl certificate file saved", slog.String("path", d.config.OutputCertPath)) } - d.logger.Info("ssl certificate file saved", slog.String("path", d.config.OutputCertPath)) } case OUTPUT_FORMAT_JKS: @@ -152,10 +158,12 @@ func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*dep } d.logger.Info("ssl certificate transformed to jks") - if err := xfile.Write(d.config.OutputCertPath, jksData); err != nil { - return nil, fmt.Errorf("failed to save certificate file: %w", err) + if d.config.OutputCertPath != "" { + if err := xfile.Write(d.config.OutputCertPath, jksData); err != nil { + return nil, fmt.Errorf("failed to save certificate file: %w", err) + } + d.logger.Info("ssl certificate file saved", slog.String("path", d.config.OutputCertPath)) } - d.logger.Info("ssl certificate file saved", slog.String("path", d.config.OutputCertPath)) } default: diff --git a/pkg/core/deployer/providers/s3/s3.go b/pkg/core/deployer/providers/s3/s3.go index 03a940e97..a64e61d60 100644 --- a/pkg/core/deployer/providers/s3/s3.go +++ b/pkg/core/deployer/providers/s3/s3.go @@ -100,10 +100,19 @@ func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*dep switch d.config.OutputFormat { case OUTPUT_FORMAT_PEM: { - if err := d.s3Client.PutObjectString(ctx, d.config.Bucket, d.config.OutputCertObjectKey, certPEM); err != nil { - return nil, fmt.Errorf("failed to upload certificate file: %w", err) + if d.config.OutputKeyObjectKey != "" { + if err := d.s3Client.PutObjectString(ctx, d.config.Bucket, d.config.OutputKeyObjectKey, privkeyPEM); err != nil { + return nil, fmt.Errorf("failed to upload private key file: %w", err) + } + d.logger.Info("ssl private key file uploaded", slog.String("bucket", d.config.Bucket), slog.String("object", d.config.OutputKeyObjectKey)) + } + + if d.config.OutputCertObjectKey != "" { + if err := d.s3Client.PutObjectString(ctx, d.config.Bucket, d.config.OutputCertObjectKey, certPEM); err != nil { + return nil, fmt.Errorf("failed to upload certificate file: %w", err) + } + d.logger.Info("ssl certificate file uploaded", slog.String("bucket", d.config.Bucket), slog.String("object", d.config.OutputCertObjectKey)) } - d.logger.Info("ssl certificate file uploaded", slog.String("bucket", d.config.Bucket), slog.String("object", d.config.OutputCertObjectKey)) if d.config.OutputServerCertObjectKey != "" { if err := d.s3Client.PutObjectString(ctx, d.config.Bucket, d.config.OutputServerCertObjectKey, serverCertPEM); err != nil { @@ -118,11 +127,6 @@ func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*dep } d.logger.Info("ssl intermedia certificate file uploaded", slog.String("bucket", d.config.Bucket), slog.String("object", d.config.OutputIntermediaCertObjectKey)) } - - if err := d.s3Client.PutObjectString(ctx, d.config.Bucket, d.config.OutputKeyObjectKey, privkeyPEM); err != nil { - return nil, fmt.Errorf("failed to upload private key file: %w", err) - } - d.logger.Info("ssl private key file uploaded", slog.String("bucket", d.config.Bucket), slog.String("object", d.config.OutputKeyObjectKey)) } case OUTPUT_FORMAT_PFX: @@ -133,10 +137,12 @@ func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*dep } d.logger.Info("ssl certificate transformed to pfx") - if err := d.s3Client.PutObjectBytes(ctx, d.config.Bucket, d.config.OutputCertObjectKey, pfxData); err != nil { - return nil, fmt.Errorf("failed to upload certificate file: %w", err) + if d.config.OutputCertObjectKey != "" { + if err := d.s3Client.PutObjectBytes(ctx, d.config.Bucket, d.config.OutputCertObjectKey, pfxData); err != nil { + return nil, fmt.Errorf("failed to upload certificate file: %w", err) + } + d.logger.Info("ssl certificate file uploaded", slog.String("bucket", d.config.Bucket), slog.String("object", d.config.OutputCertObjectKey)) } - d.logger.Info("ssl certificate file uploaded", slog.String("bucket", d.config.Bucket), slog.String("object", d.config.OutputCertObjectKey)) } case OUTPUT_FORMAT_JKS: @@ -147,10 +153,12 @@ func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*dep } d.logger.Info("ssl certificate transformed to jks") - if err := d.s3Client.PutObjectBytes(ctx, d.config.Bucket, d.config.OutputCertObjectKey, jksData); err != nil { - return nil, fmt.Errorf("failed to upload certificate file: %w", err) + if d.config.OutputCertObjectKey != "" { + if err := d.s3Client.PutObjectBytes(ctx, d.config.Bucket, d.config.OutputCertObjectKey, jksData); err != nil { + return nil, fmt.Errorf("failed to upload certificate file: %w", err) + } + d.logger.Info("ssl certificate file uploaded", slog.String("bucket", d.config.Bucket), slog.String("object", d.config.OutputCertObjectKey)) } - d.logger.Info("ssl certificate file uploaded", slog.String("bucket", d.config.Bucket), slog.String("object", d.config.OutputCertObjectKey)) } default: diff --git a/pkg/core/deployer/providers/ssh/ssh.go b/pkg/core/deployer/providers/ssh/ssh.go index 53ebc001d..e58b46165 100644 --- a/pkg/core/deployer/providers/ssh/ssh.go +++ b/pkg/core/deployer/providers/ssh/ssh.go @@ -15,7 +15,7 @@ import ( type ServerConfig struct { // SSH 主机。 - SshHost string `json:"sshHost,omitempty"` + SshHost string `json:"sshHost"` // SSH 端口。 // 零值时默认值 22。 SshPort int32 `json:"sshPort,omitempty"` @@ -110,6 +110,7 @@ func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*dep } d.logger.Info("ssh connected") + defer client.Close() // 执行前置命令 if d.config.PreCommand != "" { @@ -134,10 +135,19 @@ func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*dep switch d.config.OutputFormat { case OUTPUT_FORMAT_PEM: { - if err := xssh.WriteRemoteString(client.RawClient(), d.config.OutputCertPath, certPEM, d.config.UseSCP); err != nil { - return nil, fmt.Errorf("failed to upload certificate file: %w", err) + if d.config.OutputKeyPath != "" { + if err := xssh.WriteRemoteString(client.RawClient(), d.config.OutputKeyPath, privkeyPEM, d.config.UseSCP); err != nil { + return nil, fmt.Errorf("failed to upload private key file: %w", err) + } + d.logger.Info("ssl private key file uploaded", slog.String("path", d.config.OutputKeyPath)) + } + + if d.config.OutputCertPath != "" { + if err := xssh.WriteRemoteString(client.RawClient(), d.config.OutputCertPath, certPEM, d.config.UseSCP); err != nil { + return nil, fmt.Errorf("failed to upload certificate file: %w", err) + } + d.logger.Info("ssl certificate file uploaded", slog.String("path", d.config.OutputCertPath)) } - d.logger.Info("ssl certificate file uploaded", slog.String("path", d.config.OutputCertPath)) if d.config.OutputServerCertPath != "" { if err := xssh.WriteRemoteString(client.RawClient(), d.config.OutputServerCertPath, serverCertPEM, d.config.UseSCP); err != nil { @@ -152,11 +162,6 @@ func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*dep } d.logger.Info("ssl intermedia certificate file uploaded", slog.String("path", d.config.OutputIntermediaCertPath)) } - - if err := xssh.WriteRemoteString(client.RawClient(), d.config.OutputKeyPath, privkeyPEM, d.config.UseSCP); err != nil { - return nil, fmt.Errorf("failed to upload private key file: %w", err) - } - d.logger.Info("ssl private key file uploaded", slog.String("path", d.config.OutputKeyPath)) } case OUTPUT_FORMAT_PFX: @@ -167,10 +172,12 @@ func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*dep } d.logger.Info("ssl certificate transformed to pfx") - if err := xssh.WriteRemote(client.RawClient(), d.config.OutputCertPath, pfxData, d.config.UseSCP); err != nil { - return nil, fmt.Errorf("failed to upload certificate file: %w", err) + if d.config.OutputCertPath != "" { + if err := xssh.WriteRemote(client.RawClient(), d.config.OutputCertPath, pfxData, d.config.UseSCP); err != nil { + return nil, fmt.Errorf("failed to upload certificate file: %w", err) + } + d.logger.Info("ssl certificate file uploaded", slog.String("path", d.config.OutputCertPath)) } - d.logger.Info("ssl certificate file uploaded", slog.String("path", d.config.OutputCertPath)) } case OUTPUT_FORMAT_JKS: @@ -181,10 +188,12 @@ func (d *Deployer) Deploy(ctx context.Context, certPEM, privkeyPEM string) (*dep } d.logger.Info("ssl certificate transformed to jks") - if err := xssh.WriteRemote(client.RawClient(), d.config.OutputCertPath, jksData, d.config.UseSCP); err != nil { - return nil, fmt.Errorf("failed to upload certificate file: %w", err) + if d.config.OutputCertPath != "" { + if err := xssh.WriteRemote(client.RawClient(), d.config.OutputCertPath, jksData, d.config.UseSCP); err != nil { + return nil, fmt.Errorf("failed to upload certificate file: %w", err) + } + d.logger.Info("ssl certificate file uploaded", slog.String("path", d.config.OutputCertPath)) } - d.logger.Info("ssl certificate file uploaded", slog.String("path", d.config.OutputCertPath)) } default: diff --git a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProvider.tsx b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProvider.tsx index 0423be741..f4d8111e8 100644 --- a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProvider.tsx +++ b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProvider.tsx @@ -49,6 +49,7 @@ import BizDeployNodeConfigFieldsProviderCTCCCloudLVDN from "./BizDeployNodeConfi import BizDeployNodeConfigFieldsProviderDogeCloudCDN from "./BizDeployNodeConfigFieldsProviderDogeCloudCDN"; import BizDeployNodeConfigFieldsProviderFlexCDN from "./BizDeployNodeConfigFieldsProviderFlexCDN"; import BizDeployNodeConfigFieldsProviderFlyIO from "./BizDeployNodeConfigFieldsProviderFlyIO"; +import BizDeployNodeConfigFieldsProviderFTP from "./BizDeployNodeConfigFieldsProviderFTP"; import BizDeployNodeConfigFieldsProviderGcoreCDN from "./BizDeployNodeConfigFieldsProviderGcoreCDN"; import BizDeployNodeConfigFieldsProviderGoEdge from "./BizDeployNodeConfigFieldsProviderGoEdge"; import BizDeployNodeConfigFieldsProviderHuaweiCloudAAD from "./BizDeployNodeConfigFieldsProviderHuaweiCloudAAD"; @@ -174,6 +175,7 @@ const providerComponentMap: Partial { + const { i18n, t } = useTranslation(); + + const { parentNamePath } = useFormNestedFieldsContext(); + const formSchema = z.object({ + [parentNamePath]: getSchema({ i18n }), + }); + const formRule = createSchemaFieldRule(formSchema); + const formInst = Form.useFormInstance(); + const initialValues = getInitialValues(); + + const fieldFormat = Form.useWatch([parentNamePath, "format"], formInst); + const fieldCertPath = Form.useWatch([parentNamePath, "certPath"], formInst); + + const handleFormatSelect = (value: string) => { + if (fieldFormat === value) return; + + switch (value) { + case FORMAT_PEM: + { + if (/(.pfx|.jks)$/.test(fieldCertPath)) { + formInst.setFieldValue([parentNamePath, "certPath"], fieldCertPath.replace(/(.pfx|.jks)$/, ".crt")); + } + } + break; + + case FORMAT_PFX: + { + if (/(.crt|.jks)$/.test(fieldCertPath)) { + formInst.setFieldValue([parentNamePath, "certPath"], fieldCertPath.replace(/(.crt|.jks)$/, ".pfx")); + } + } + break; + + case FORMAT_JKS: + { + if (/(.crt|.pfx)$/.test(fieldCertPath)) { + formInst.setFieldValue([parentNamePath, "certPath"], fieldCertPath.replace(/(.crt|.pfx)$/, ".jks")); + } + } + break; + } + }; + + const handleRandomPfxPasswordClick = () => { + const password = randomString(); + formInst.setFieldValue([parentNamePath, "pfxPassword"], password); + }; + + const handleRandomJksKeypassClick = () => { + const password = randomString(); + formInst.setFieldValue([parentNamePath, "jksKeypass"], password); + }; + + const handleRandomJksStorepassClick = () => { + const password = randomString(); + formInst.setFieldValue([parentNamePath, "jksStorepass"], password); + }; + + return ( + <> + + + + + + + + + + + + + + + + + + + + + } + > + + + + + + + + + + + + + } + > + + + + } + > + + + + + + + + + + + } + > + + + + + + + + + + + + ); +}; + +const getInitialValues = (): Nullish>> => { + return { + format: FORMAT_PEM, + keyPath: "/certimate/cert.key", + certPath: "/certimate/cert.crt", + }; +}; + +const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType }) => { + const { t } = i18n; + + return z + .object({ + format: z.literal([FORMAT_PEM, FORMAT_PFX, FORMAT_JKS], t("workflow_node.deploy.form.ftp_format.placeholder")), + keyPath: z + .string() + .max(256, t("common.errmsg.string_max", { max: 256 })) + .nullish(), + certPath: z + .string() + .min(1, t("workflow_node.deploy.form.ftp_cert_path.placeholder")) + .max(256, t("common.errmsg.string_max", { max: 256 })), + certPathForServerOnly: z + .string() + .max(256, t("common.errmsg.string_max", { max: 256 })) + .nullish(), + certPathForIntermediaOnly: z + .string() + .max(256, t("common.errmsg.string_max", { max: 256 })) + .nullish(), + pfxPassword: z.string().nullish(), + jksAlias: z.string().nullish(), + jksKeypass: z.string().nullish(), + jksStorepass: z.string().nullish(), + }) + .superRefine((values, ctx) => { + switch (values.format) { + case FORMAT_PEM: + { + if (!values.keyPath?.trim()) { + ctx.addIssue({ + code: "custom", + message: t("workflow_node.deploy.form.ftp_key_path.placeholder"), + path: ["keyPath"], + }); + } + } + break; + + case FORMAT_PFX: + { + if (!values.pfxPassword?.trim()) { + ctx.addIssue({ + code: "custom", + message: t("workflow_node.deploy.form.ftp_pfx_password.placeholder"), + path: ["pfxPassword"], + }); + } + } + break; + + case FORMAT_JKS: + { + if (!values.jksAlias?.trim()) { + ctx.addIssue({ + code: "custom", + message: t("workflow_node.deploy.form.ftp_jks_alias.placeholder"), + path: ["jksAlias"], + }); + } + + if (!values.jksKeypass?.trim()) { + ctx.addIssue({ + code: "custom", + message: t("workflow_node.deploy.form.ftp_jks_keypass.placeholder"), + path: ["jksKeypass"], + }); + } + + if (!values.jksStorepass?.trim()) { + ctx.addIssue({ + code: "custom", + message: t("workflow_node.deploy.form.ftp_jks_storepass.placeholder"), + path: ["jksStorepass"], + }); + } + } + break; + } + }); +}; + +const _default = Object.assign(BizDeployNodeConfigFieldsProviderFTP, { + getInitialValues, + getSchema, +}); + +export default _default; diff --git a/ui/src/domain/provider.ts b/ui/src/domain/provider.ts index 3a4845a64..eafdf4d94 100644 --- a/ui/src/domain/provider.ts +++ b/ui/src/domain/provider.ts @@ -610,6 +610,7 @@ export const DEPLOYMENT_PROVIDERS = Object.freeze({ DOKPLOY: `${ACCESS_PROVIDERS.DOKPLOY}`, FLEXCDN: `${ACCESS_PROVIDERS.FLEXCDN}`, FLYIO: `${ACCESS_PROVIDERS.FLYIO}`, + FTP: `${ACCESS_PROVIDERS.FTP}`, GCORE_CDN: `${ACCESS_PROVIDERS.GCORE}-cdn`, GOEDGE: `${ACCESS_PROVIDERS.GOEDGE}`, HUAWEICLOUD_APIG: `${ACCESS_PROVIDERS.HUAWEICLOUD}-apig`, @@ -720,6 +721,7 @@ export const deploymentProvidersMap: Maphttps://learn.microsoft.com/en-us/windows-hardware/drivers/install/personal-information-exchange---pfx--files", + "workflow_node.deploy.form.ftp_jks_alias.label": "JKS alias", + "workflow_node.deploy.form.ftp_jks_alias.placeholder": "Please enter JKS alias", + "workflow_node.deploy.form.ftp_jks_alias.tooltip": "For more information, see https://docs.oracle.com/cd/E19509-01/820-3503/ggfen/index.html", + "workflow_node.deploy.form.ftp_jks_keypass.label": "JKS key password", + "workflow_node.deploy.form.ftp_jks_keypass.placeholder": "Please enter JKS key password", + "workflow_node.deploy.form.ftp_jks_keypass.tooltip": "For more information, see https://docs.oracle.com/cd/E19509-01/820-3503/ggfen/index.html", + "workflow_node.deploy.form.ftp_jks_storepass.label": "JKS store password", + "workflow_node.deploy.form.ftp_jks_storepass.placeholder": "Please enter JKS store password", + "workflow_node.deploy.form.ftp_jks_storepass.tooltip": "For more information, see https://docs.oracle.com/cd/E19509-01/820-3503/ggfen/index.html", "workflow_node.deploy.form.gcore_cdn_resource_id.label": "G-Core CDN resource ID", "workflow_node.deploy.form.gcore_cdn_resource_id.placeholder": "Please enter G-Core CDN resource ID", "workflow_node.deploy.form.gcore_cdn_resource_id.tooltip": "For more information, see https://cdn.gcore.com/resources/list", diff --git a/ui/src/i18n/locales/zh/nls.workflow.nodes.json b/ui/src/i18n/locales/zh/nls.workflow.nodes.json index 2b4ef9193..6d4c2d294 100644 --- a/ui/src/i18n/locales/zh/nls.workflow.nodes.json +++ b/ui/src/i18n/locales/zh/nls.workflow.nodes.json @@ -581,6 +581,37 @@ "workflow_node.deploy.form.flyio_domain.label": "Fly.io 自定义域名", "workflow_node.deploy.form.flyio_domain.placeholder": "请输入 Fly.io 自定义域名", "workflow_node.deploy.form.flyio_domain.help": "注意:首次导入自定义证书后,请登录 Fly.io 控制台完成域名所有权验证。", + "workflow_node.deploy.form.ftp_format.label": "文件格式", + "workflow_node.deploy.form.ftp_format.placeholder": "请选择文件格式", + "workflow_node.deploy.form.ftp_format.option.pem.label": "PEM 格式(*.pem, *.crt, *.key)", + "workflow_node.deploy.form.ftp_format.option.pfx.label": "PFX 格式(*.pfx, *.p12)", + "workflow_node.deploy.form.ftp_format.option.jks.label": "JKS 格式(*.jks)", + "workflow_node.deploy.form.ftp_key_path.label": "私钥文件保存路径", + "workflow_node.deploy.form.ftp_key_path.placeholder": "请输入私钥文件远程路径", + "workflow_node.deploy.form.ftp_key_path.help": "注意:路径需包含完整的文件名,而不是只有目录。", + "workflow_node.deploy.form.ftp_cert_path.label": "证书文件保存路径", + "workflow_node.deploy.form.ftp_cert_path.placeholder": "请输入证书文件远程路径", + "workflow_node.deploy.form.ftp_cert_path.help": "注意:路径需包含完整的文件名,而不是只有目录。", + "workflow_node.deploy.form.ftp_fullchaincert_path.label": "证书链文件保存路径", + "workflow_node.deploy.form.ftp_fullchaincert_path.placeholder": "请输入证书链文件远程路径", + "workflow_node.deploy.form.ftp_servercert_path.label": "服务器证书文件保存路径(可选)", + "workflow_node.deploy.form.ftp_servercert_path.placeholder": "请输入服务器证书文件远程路径", + "workflow_node.deploy.form.ftp_servercert_path.help": "注意:路径需包含完整的文件名,而不是只有目录。不填写时将不上传服务器证书。", + "workflow_node.deploy.form.ftp_intermediacert_path.label": "中间证书文件保存路径(可选)", + "workflow_node.deploy.form.ftp_intermediacert_path.placeholder": "请输入中间证书文件远程路径", + "workflow_node.deploy.form.ftp_intermediacert_path.help": "注意:路径需包含完整的文件名,而不是只有目录。不填写时将不上传中间证书。", + "workflow_node.deploy.form.ftp_pfx_password.label": "PFX 导出密码", + "workflow_node.deploy.form.ftp_pfx_password.placeholder": "请输入 PFX 导出密码", + "workflow_node.deploy.form.ftp_pfx_password.tooltip": "这是什么?请参阅 https://learn.microsoft.com/zh-cn/windows-hardware/drivers/install/personal-information-exchange---pfx--files", + "workflow_node.deploy.form.ftp_jks_alias.label": "JKS 别名", + "workflow_node.deploy.form.ftp_jks_alias.placeholder": "请输入 JKS 别名", + "workflow_node.deploy.form.ftp_jks_alias.tooltip": "这是什么?请参阅 https://docs.oracle.com/cd/E19509-01/820-3503/ggfen/index.html", + "workflow_node.deploy.form.ftp_jks_keypass.label": "JKS 私钥访问口令", + "workflow_node.deploy.form.ftp_jks_keypass.placeholder": "请输入 JKS 私钥访问口令", + "workflow_node.deploy.form.ftp_jks_keypass.tooltip": "这是什么?请参阅 https://docs.oracle.com/cd/E19509-01/820-3503/ggfen/index.html", + "workflow_node.deploy.form.ftp_jks_storepass.label": "JKS 密钥库存储口令", + "workflow_node.deploy.form.ftp_jks_storepass.placeholder": "请输入 JKS 密钥库存储口令", + "workflow_node.deploy.form.ftp_jks_storepass.tooltip": "这是什么?请参阅 https://docs.oracle.com/cd/E19509-01/820-3503/ggfen/index.html", "workflow_node.deploy.form.gcore_cdn_resource_id.label": "G-Core CDN 资源 ID", "workflow_node.deploy.form.gcore_cdn_resource_id.placeholder": "请输入 G-Core CDN 资源 ID", "workflow_node.deploy.form.gcore_cdn_resource_id.tooltip": "这是什么?请参阅 https://cdn.gcore.com/resources/list",