feat(provider): new deployment provider: ftp

This commit is contained in:
Fu Diwei
2026-04-17 11:50:28 +08:00
committed by RHQYZ
parent f2b4fd1065
commit 3d94f30f5d
14 changed files with 787 additions and 44 deletions
+36
View File
@@ -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
})
}
+1
View File
@@ -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")
@@ -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"`
+11
View File
@@ -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)
)
+213
View File
@@ -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
}
@@ -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)
})
}
+22 -14
View File
@@ -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:
+22 -14
View File
@@ -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:
+24 -15
View File
@@ -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:
@@ -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<Record<DeploymentProviderType, React.Compone
[DEPLOYMENT_PROVIDERS.DOGECLOUD_CDN]: BizDeployNodeConfigFieldsProviderDogeCloudCDN,
[DEPLOYMENT_PROVIDERS.FLEXCDN]: BizDeployNodeConfigFieldsProviderFlexCDN,
[DEPLOYMENT_PROVIDERS.FLYIO]: BizDeployNodeConfigFieldsProviderFlyIO,
[DEPLOYMENT_PROVIDERS.FTP]: BizDeployNodeConfigFieldsProviderFTP,
[DEPLOYMENT_PROVIDERS.GCORE_CDN]: BizDeployNodeConfigFieldsProviderGcoreCDN,
[DEPLOYMENT_PROVIDERS.GOEDGE]: BizDeployNodeConfigFieldsProviderGoEdge,
[DEPLOYMENT_PROVIDERS.HUAWEICLOUD_AAD]: BizDeployNodeConfigFieldsProviderHuaweiCloudAAD,
@@ -0,0 +1,302 @@
import { getI18n, useTranslation } from "react-i18next";
import { IconDice6 } from "@tabler/icons-react";
import { Button, Form, Input, Select, Space, Tooltip } from "antd";
import { createSchemaFieldRule } from "antd-zod";
import { z } from "zod";
import Show from "@/components/Show";
import { CERTIFICATE_FORMATS } from "@/domain/certificate";
import { randomString } from "@/utils/random";
import { useFormNestedFieldsContext } from "./_context";
import { initPresetScript as _initPresetScript } from "./BizDeployNodeConfigFieldsProviderLocal";
const FORMAT_PEM = CERTIFICATE_FORMATS.PEM;
const FORMAT_PFX = CERTIFICATE_FORMATS.PFX;
const FORMAT_JKS = CERTIFICATE_FORMATS.JKS;
const BizDeployNodeConfigFieldsProviderFTP = () => {
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 (
<>
<Form.Item
name={[parentNamePath, "format"]}
initialValue={initialValues.format}
label={t("workflow_node.deploy.form.ftp_format.label")}
rules={[formRule]}
>
<Select
options={[FORMAT_PEM, FORMAT_PFX, FORMAT_JKS].map((s) => ({
key: s,
label: t(`workflow_node.deploy.form.ftp_format.option.${s.toLowerCase()}.label`),
value: s,
}))}
placeholder={t("workflow_node.deploy.form.ftp_format.placeholder")}
onSelect={handleFormatSelect}
/>
</Form.Item>
<Show when={fieldFormat === FORMAT_PEM}>
<Form.Item
name={[parentNamePath, "keyPath"]}
initialValue={initialValues.keyPath}
label={t("workflow_node.deploy.form.ftp_key_path.label")}
extra={t("workflow_node.deploy.form.ftp_key_path.help")}
rules={[formRule]}
>
<Input placeholder={t("workflow_node.deploy.form.ftp_key_path.placeholder")} />
</Form.Item>
</Show>
<Form.Item
name={[parentNamePath, "certPath"]}
initialValue={initialValues.certPath}
label={t(`workflow_node.deploy.form.ftp_${fieldFormat === FORMAT_PEM ? "fullchaincert" : "cert"}_path.label`)}
extra={t("workflow_node.deploy.form.ftp_cert_path.help")}
rules={[formRule]}
>
<Input placeholder={t(`workflow_node.deploy.form.ftp_${fieldFormat === FORMAT_PEM ? "fullchaincert" : "cert"}_path.placeholder`)} />
</Form.Item>
<Show when={fieldFormat === FORMAT_PEM}>
<Form.Item
name={[parentNamePath, "certPathForServerOnly"]}
initialValue={initialValues.certPathForServerOnly}
label={t("workflow_node.deploy.form.ftp_servercert_path.label")}
extra={t("workflow_node.deploy.form.ftp_servercert_path.help")}
rules={[formRule]}
>
<Input allowClear placeholder={t("workflow_node.deploy.form.ftp_servercert_path.placeholder")} />
</Form.Item>
<Form.Item
name={[parentNamePath, "certPathForIntermediaOnly"]}
initialValue={initialValues.certPathForIntermediaOnly}
label={t("workflow_node.deploy.form.ftp_intermediacert_path.label")}
extra={t("workflow_node.deploy.form.ftp_intermediacert_path.help")}
rules={[formRule]}
>
<Input allowClear placeholder={t("workflow_node.deploy.form.ftp_intermediacert_path.placeholder")} />
</Form.Item>
</Show>
<Show when={fieldFormat === FORMAT_PFX}>
<Form.Item
label={t("workflow_node.deploy.form.ftp_pfx_password.label")}
tooltip={<span dangerouslySetInnerHTML={{ __html: t("workflow_node.deploy.form.ftp_pfx_password.tooltip") }}></span>}
>
<Space.Compact className="w-full">
<Form.Item name={[parentNamePath, "pfxPassword"]} initialValue={initialValues.pfxPassword} rules={[formRule]} noStyle>
<Input placeholder={t("workflow_node.deploy.form.ftp_pfx_password.placeholder")} />
</Form.Item>
<Tooltip title={t("common.text.random_roll")}>
<Button className="px-2" onClick={handleRandomPfxPasswordClick}>
<IconDice6 size="1.25em" />
</Button>
</Tooltip>
</Space.Compact>
</Form.Item>
</Show>
<Show when={fieldFormat === FORMAT_JKS}>
<Form.Item
name={[parentNamePath, "jksAlias"]}
initialValue={initialValues.jksAlias}
label={t("workflow_node.deploy.form.ftp_jks_alias.label")}
rules={[formRule]}
tooltip={<span dangerouslySetInnerHTML={{ __html: t("workflow_node.deploy.form.ftp_jks_alias.tooltip") }}></span>}
>
<Input placeholder={t("workflow_node.deploy.form.ftp_jks_alias.placeholder")} />
</Form.Item>
<Form.Item
label={t("workflow_node.deploy.form.ftp_jks_keypass.label")}
tooltip={<span dangerouslySetInnerHTML={{ __html: t("workflow_node.deploy.form.ftp_jks_keypass.tooltip") }}></span>}
>
<Space.Compact className="w-full">
<Form.Item name={[parentNamePath, "jksKeypass"]} initialValue={initialValues.jksKeypass} rules={[formRule]} noStyle>
<Input placeholder={t("workflow_node.deploy.form.ftp_jks_keypass.placeholder")} />
</Form.Item>
<Tooltip title={t("common.text.random_roll")}>
<Button className="px-2" onClick={handleRandomJksKeypassClick}>
<IconDice6 size="1.25em" />
</Button>
</Tooltip>
</Space.Compact>
</Form.Item>
<Form.Item
label={t("workflow_node.deploy.form.ftp_jks_storepass.label")}
tooltip={<span dangerouslySetInnerHTML={{ __html: t("workflow_node.deploy.form.ftp_jks_storepass.tooltip") }}></span>}
>
<Space.Compact className="w-full">
<Form.Item name={[parentNamePath, "jksStorepass"]} initialValue={initialValues.jksStorepass} rules={[formRule]} noStyle>
<Input placeholder={t("workflow_node.deploy.form.ftp_jks_storepass.placeholder")} />
</Form.Item>
<Tooltip title={t("common.text.random_roll")}>
<Button className="px-2" onClick={handleRandomJksStorepassClick}>
<IconDice6 size="1.25em" />
</Button>
</Tooltip>
</Space.Compact>
</Form.Item>
</Show>
</>
);
};
const getInitialValues = (): Nullish<z.infer<ReturnType<typeof getSchema>>> => {
return {
format: FORMAT_PEM,
keyPath: "/certimate/cert.key",
certPath: "/certimate/cert.crt",
};
};
const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType<typeof getI18n> }) => {
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;
+2
View File
@@ -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: Map<DeploymentProvider["type"] | string, De
[
[DEPLOYMENT_PROVIDERS.LOCAL, "provider.local", DEPLOYMENT_CATEGORIES.OTHER, "builtin"],
[DEPLOYMENT_PROVIDERS.SSH, "provider.ssh", DEPLOYMENT_CATEGORIES.OTHER],
[DEPLOYMENT_PROVIDERS.FTP, "provider.ftp", DEPLOYMENT_CATEGORIES.OTHER],
[DEPLOYMENT_PROVIDERS.WEBHOOK, "provider.webhook", DEPLOYMENT_CATEGORIES.OTHER],
[DEPLOYMENT_PROVIDERS.KUBERNETES_SECRET, "provider.kubernetes_secret", DEPLOYMENT_CATEGORIES.OTHER],
[DEPLOYMENT_PROVIDERS.S3, "provider.s3_upload", DEPLOYMENT_CATEGORIES.STORAGE],
@@ -583,6 +583,37 @@
"workflow_node.deploy.form.flyio_domain.label": "Fly.io custom domain",
"workflow_node.deploy.form.flyio_domain.placeholder": "Please enter Fly.io custom domain name",
"workflow_node.deploy.form.flyio_domain.help": "Notes: After importing the custom certificate for the first time, you need to complete domain ownership verification on Fly.io dashboard.",
"workflow_node.deploy.form.ftp_format.label": "File format",
"workflow_node.deploy.form.ftp_format.placeholder": "Please select file format",
"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": "Private key file path",
"workflow_node.deploy.form.ftp_key_path.placeholder": "Please enter the remote path for private key file",
"workflow_node.deploy.form.ftp_key_path.help": "Notes: It should include the full file path, not just the directory.",
"workflow_node.deploy.form.ftp_cert_path.label": "Certificate file path",
"workflow_node.deploy.form.ftp_cert_path.placeholder": "Please enter the remote path for certificate",
"workflow_node.deploy.form.ftp_cert_path.help": "Notes: It should include the full file path, not just the directory.",
"workflow_node.deploy.form.ftp_fullchaincert_path.label": "Bundled fullchain certificate file path",
"workflow_node.deploy.form.ftp_fullchaincert_path.placeholder": "Please enter the remote path for bundled fullchain certificate",
"workflow_node.deploy.form.ftp_servercert_path.label": "Server certificate file path (Optional)",
"workflow_node.deploy.form.ftp_servercert_path.placeholder": "Please enter the remote path for server certificate file",
"workflow_node.deploy.form.ftp_servercert_path.help": "Notes: It should include the full file path, not just the directory.",
"workflow_node.deploy.form.ftp_intermediacert_path.label": "Intermediate CA certificate file path (Optional)",
"workflow_node.deploy.form.ftp_intermediacert_path.placeholder": "Please enter the remote path for intermediate CA certificate file",
"workflow_node.deploy.form.ftp_intermediacert_path.help": "Notes: It should include the full file path, not just the directory.",
"workflow_node.deploy.form.ftp_pfx_password.label": "PFX password",
"workflow_node.deploy.form.ftp_pfx_password.placeholder": "Please enter PFX password",
"workflow_node.deploy.form.ftp_pfx_password.tooltip": "For more information, see <a href=\"https://learn.microsoft.com/en-us/windows-hardware/drivers/install/personal-information-exchange---pfx--files\" target=\"_blank\">https://learn.microsoft.com/en-us/windows-hardware/drivers/install/personal-information-exchange---pfx--files</a>",
"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 <a href=\"https://docs.oracle.com/cd/E19509-01/820-3503/ggfen/index.html\" target=\"_blank\">https://docs.oracle.com/cd/E19509-01/820-3503/ggfen/index.html</a>",
"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 <a href=\"https://docs.oracle.com/cd/E19509-01/820-3503/ggfen/index.html\" target=\"_blank\">https://docs.oracle.com/cd/E19509-01/820-3503/ggfen/index.html</a>",
"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 <a href=\"https://docs.oracle.com/cd/E19509-01/820-3503/ggfen/index.html\" target=\"_blank\">https://docs.oracle.com/cd/E19509-01/820-3503/ggfen/index.html</a>",
"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 <a href=\"https://cdn.gcore.com/resources/list\" target=\"_blank\">https://cdn.gcore.com/resources/list</a>",
@@ -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": "这是什么?请参阅 <a href=\"https://learn.microsoft.com/zh-cn/windows-hardware/drivers/install/personal-information-exchange---pfx--files\" target=\"_blank\">https://learn.microsoft.com/zh-cn/windows-hardware/drivers/install/personal-information-exchange---pfx--files</a>",
"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": "这是什么?请参阅 <a href=\"https://docs.oracle.com/cd/E19509-01/820-3503/ggfen/index.html\" target=\"_blank\">https://docs.oracle.com/cd/E19509-01/820-3503/ggfen/index.html</a>",
"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": "这是什么?请参阅 <a href=\"https://docs.oracle.com/cd/E19509-01/820-3503/ggfen/index.html\" target=\"_blank\">https://docs.oracle.com/cd/E19509-01/820-3503/ggfen/index.html</a>",
"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": "这是什么?请参阅 <a href=\"https://docs.oracle.com/cd/E19509-01/820-3503/ggfen/index.html\" target=\"_blank\">https://docs.oracle.com/cd/E19509-01/820-3503/ggfen/index.html</a>",
"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": "这是什么?请参阅 <a href=\"https://cdn.gcore.com/resources/list\" target=\"_blank\">https://cdn.gcore.com/resources/list</a>",