diff --git a/cmd/intercmd.go b/cmd/intercmd.go index 1fc790b49..49268217b 100644 --- a/cmd/intercmd.go +++ b/cmd/intercmd.go @@ -69,7 +69,7 @@ func internalCertApplyCommand(app core.App) *cobra.Command { return nil, fmt.Errorf("failed to initialize acme client: %w", err) } - resp, err := client.ObtainCertificateWithContext(ctx, params.Request) + resp, err := client.ObtainCertificate(ctx, params.Request) if err != nil { return nil, fmt.Errorf("failed to obtain certificate: %w", err) } diff --git a/internal/certapply/applicators/sp_ssh.go b/internal/certapply/applicators/sp_ssh.go new file mode 100644 index 000000000..d4d6c7fc3 --- /dev/null +++ b/internal/certapply/applicators/sp_ssh.go @@ -0,0 +1,51 @@ +package applicators + +import ( + "fmt" + + "github.com/go-acme/lego/v4/challenge" + + "github.com/certimate-go/certimate/internal/domain" + "github.com/certimate-go/certimate/pkg/core/ssl-applicator/acme-http01/providers/ssh" + xmaps "github.com/certimate-go/certimate/pkg/utils/maps" +) + +func init() { + if err := ACMEHttp01Registries.Register(domain.ACMEHttp01ProviderTypeSSH, func(options *ProviderFactoryOptions) (challenge.Provider, error) { + credentials := domain.AccessConfigForSSH{} + if err := xmaps.Populate(options.ProviderAccessConfig, &credentials); err != nil { + return nil, fmt.Errorf("failed to populate provider access config: %w", err) + } + + jumpServers := make([]ssh.ServerConfig, len(credentials.JumpServers)) + for i, jumpServer := range credentials.JumpServers { + jumpServers[i] = ssh.ServerConfig{ + SshHost: jumpServer.Host, + SshPort: jumpServer.Port, + SshAuthMethod: jumpServer.AuthMethod, + SshUsername: jumpServer.Username, + SshPassword: jumpServer.Password, + SshKey: jumpServer.Key, + SshKeyPassphrase: jumpServer.KeyPassphrase, + } + } + + provider, err := ssh.NewChallengeProvider(&ssh.ChallengeProviderConfig{ + ServerConfig: ssh.ServerConfig{ + SshHost: credentials.Host, + SshPort: credentials.Port, + SshAuthMethod: credentials.AuthMethod, + SshUsername: credentials.Username, + SshPassword: credentials.Password, + SshKey: credentials.Key, + SshKeyPassphrase: credentials.KeyPassphrase, + }, + JumpServers: jumpServers, + UseSCP: xmaps.GetBool(options.ProviderExtendedConfig, "useSCP"), + WebRootPath: xmaps.GetString(options.ProviderExtendedConfig, "webRootPath"), + }) + return provider, err + }); err != nil { + panic(err) + } +} diff --git a/internal/certapply/client_certifier.go b/internal/certapply/client_certifier.go index 30c1574e7..cf144bd77 100644 --- a/internal/certapply/client_certifier.go +++ b/internal/certapply/client_certifier.go @@ -63,7 +63,7 @@ type ObtainCertificateResponse struct { ARIReplaced bool } -func (c *ACMEClient) ObtainCertificateWithContext(ctx context.Context, request *ObtainCertificateRequest) (*ObtainCertificateResponse, error) { +func (c *ACMEClient) ObtainCertificate(ctx context.Context, request *ObtainCertificateRequest) (*ObtainCertificateResponse, error) { type result struct { res *ObtainCertificateResponse err error @@ -72,7 +72,7 @@ func (c *ACMEClient) ObtainCertificateWithContext(ctx context.Context, request * done := make(chan result, 1) go func() { - res, err := c.ObtainCertificate(request) + res, err := c.sendObtainCertificateRequest(request) done <- result{res, err} }() @@ -84,7 +84,7 @@ func (c *ACMEClient) ObtainCertificateWithContext(ctx context.Context, request * } } -func (c *ACMEClient) ObtainCertificate(request *ObtainCertificateRequest) (*ObtainCertificateResponse, error) { +func (c *ACMEClient) sendObtainCertificateRequest(request *ObtainCertificateRequest) (*ObtainCertificateResponse, error) { if request == nil { return nil, errors.New("the request is nil") } diff --git a/internal/certdeploy/client.go b/internal/certdeploy/client.go new file mode 100644 index 000000000..956b4b37e --- /dev/null +++ b/internal/certdeploy/client.go @@ -0,0 +1,25 @@ +package certdeploy + +import ( + "log/slog" +) + +type Client struct { + logger *slog.Logger +} + +type ClientConfigure func(*Client) + +func NewClient(configures ...ClientConfigure) *Client { + client := &Client{} + for _, configure := range configures { + configure(client) + } + return client +} + +func WithLogger(logger *slog.Logger) ClientConfigure { + return func(c *Client) { + c.logger = logger + } +} diff --git a/internal/certdeploy/client_deployer.go b/internal/certdeploy/client_deployer.go new file mode 100644 index 000000000..d42952923 --- /dev/null +++ b/internal/certdeploy/client_deployer.go @@ -0,0 +1,49 @@ +package certdeploy + +import ( + "context" + "errors" + "fmt" + + "github.com/certimate-go/certimate/internal/certdeploy/deployers" + "github.com/certimate-go/certimate/internal/domain" +) + +type DeployCertificateRequest struct { + // 提供商相关 + Provider string + ProviderAccessConfig map[string]any + ProviderExtendedConfig map[string]any + + // 证书相关 + Certificate string + PrivateKey string +} + +type DeployCertificateResponse struct{} + +func (c *Client) DeployCertificate(ctx context.Context, request *DeployCertificateRequest) (*DeployCertificateResponse, error) { + if request == nil { + return nil, errors.New("the request is nil") + } + + providerFactory, err := deployers.Registries.Get(domain.DeploymentProviderType(request.Provider)) + if err != nil { + return nil, err + } + + provider, err := providerFactory(&deployers.ProviderFactoryOptions{ + ProviderAccessConfig: request.ProviderAccessConfig, + ProviderExtendedConfig: request.ProviderExtendedConfig, + }) + if err != nil { + return nil, fmt.Errorf("failed to initialize deployment provider '%s': %w", request.Provider, err) + } + + provider.SetLogger(c.logger) + if _, err := provider.Deploy(ctx, request.Certificate, request.PrivateKey); err != nil { + return nil, err + } + + return &DeployCertificateResponse{}, nil +} diff --git a/internal/certdeploy/deployers/sp_ssh.go b/internal/certdeploy/deployers/sp_ssh.go index ee81c0f17..e939e6f16 100644 --- a/internal/certdeploy/deployers/sp_ssh.go +++ b/internal/certdeploy/deployers/sp_ssh.go @@ -16,9 +16,9 @@ func init() { return nil, fmt.Errorf("failed to populate provider access config: %w", err) } - jumpServers := make([]ssh.JumpServerConfig, len(credentials.JumpServers)) + jumpServers := make([]ssh.ServerConfig, len(credentials.JumpServers)) for i, jumpServer := range credentials.JumpServers { - jumpServers[i] = ssh.JumpServerConfig{ + jumpServers[i] = ssh.ServerConfig{ SshHost: jumpServer.Host, SshPort: jumpServer.Port, SshAuthMethod: jumpServer.AuthMethod, @@ -30,13 +30,15 @@ func init() { } provider, err := ssh.NewSSLDeployerProvider(&ssh.SSLDeployerProviderConfig{ - SshHost: credentials.Host, - SshPort: credentials.Port, - SshAuthMethod: credentials.AuthMethod, - SshUsername: credentials.Username, - SshPassword: credentials.Password, - SshKey: credentials.Key, - SshKeyPassphrase: credentials.KeyPassphrase, + ServerConfig: ssh.ServerConfig{ + SshHost: credentials.Host, + SshPort: credentials.Port, + SshAuthMethod: credentials.AuthMethod, + SshUsername: credentials.Username, + SshPassword: credentials.Password, + SshKey: credentials.Key, + SshKeyPassphrase: credentials.KeyPassphrase, + }, JumpServers: jumpServers, UseSCP: xmaps.GetBool(options.ProviderExtendedConfig, "useSCP"), PreCommand: xmaps.GetString(options.ProviderExtendedConfig, "preCommand"), diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go deleted file mode 100644 index 13cdbef84..000000000 --- a/internal/deployer/deployer.go +++ /dev/null @@ -1,75 +0,0 @@ -package deployer - -import ( - "context" - "fmt" - "log/slog" - - "github.com/certimate-go/certimate/internal/domain" - "github.com/certimate-go/certimate/internal/repository" - "github.com/certimate-go/certimate/pkg/core" -) - -type Deployer interface { - Deploy(ctx context.Context) error -} - -type DeployerWithWorkflowNodeConfig struct { - Node *domain.WorkflowNode - Logger *slog.Logger - CertificatePEM string - PrivateKeyPEM string -} - -// TODO: refactor -func NewWithWorkflowNode(config DeployerWithWorkflowNodeConfig) (Deployer, error) { - if config.Node == nil { - return nil, fmt.Errorf("the node is nil") - } - if config.Node.Type != domain.WorkflowNodeTypeBizDeploy { - return nil, fmt.Errorf("the node type is '%s', expected '%s'", string(config.Node.Type), string(domain.WorkflowNodeTypeBizDeploy)) - } - - nodeCfg := config.Node.Data.Config.AsBizDeploy() - options := &deployerProviderOptions{ - Provider: domain.DeploymentProviderType(nodeCfg.Provider), - ProviderAccessConfig: make(map[string]any), - ProviderExtendedConfig: nodeCfg.ProviderConfig, - } - - accessRepo := repository.NewAccessRepository() - if nodeCfg.ProviderAccessId != "" { - access, err := accessRepo.GetById(context.Background(), nodeCfg.ProviderAccessId) - if err != nil { - return nil, fmt.Errorf("failed to get access #%s record: %w", nodeCfg.ProviderAccessId, err) - } else { - options.ProviderAccessConfig = access.Config - } - } - - deployer, err := createSSLDeployerProvider(options) - if err != nil { - return nil, err - } else { - deployer.SetLogger(config.Logger) - } - - return &deployerImpl{ - provider: deployer, - certPEM: config.CertificatePEM, - privkeyPEM: config.PrivateKeyPEM, - }, nil -} - -type deployerImpl struct { - provider core.SSLDeployer - certPEM string - privkeyPEM string -} - -var _ Deployer = (*deployerImpl)(nil) - -func (d *deployerImpl) Deploy(ctx context.Context) error { - _, err := d.provider.Deploy(ctx, d.certPEM, d.privkeyPEM) - return err -} diff --git a/internal/deployer/providers.go b/internal/deployer/providers.go deleted file mode 100644 index 18663d28e..000000000 --- a/internal/deployer/providers.go +++ /dev/null @@ -1,25 +0,0 @@ -package deployer - -import ( - "github.com/certimate-go/certimate/internal/certdeploy/deployers" - "github.com/certimate-go/certimate/internal/domain" - "github.com/certimate-go/certimate/pkg/core" -) - -type deployerProviderOptions struct { - Provider domain.DeploymentProviderType - ProviderAccessConfig map[string]any - ProviderExtendedConfig map[string]any -} - -func createSSLDeployerProvider(options *deployerProviderOptions) (core.SSLDeployer, error) { - provider, err := deployers.Registries.Get(options.Provider) - if err != nil { - return nil, err - } - - return provider(&deployers.ProviderFactoryOptions{ - ProviderAccessConfig: options.ProviderAccessConfig, - ProviderExtendedConfig: options.ProviderExtendedConfig, - }) -} diff --git a/internal/domain/provider.go b/internal/domain/provider.go index c7f32c2c9..e35919a7c 100644 --- a/internal/domain/provider.go +++ b/internal/domain/provider.go @@ -185,6 +185,7 @@ NOTICE: If you add new constant, please keep ASCII order. */ const ( ACMEHttp01ProviderTypeLocal = ACMEHttp01ProviderType(AccessProviderTypeLocal) + ACMEHttp01ProviderTypeSSH = ACMEHttp01ProviderType(AccessProviderTypeSSH) ) type DeploymentProviderType string diff --git a/internal/notify/client.go b/internal/notify/client.go new file mode 100644 index 000000000..7b5de5c8d --- /dev/null +++ b/internal/notify/client.go @@ -0,0 +1,25 @@ +package notify + +import ( + "log/slog" +) + +type Client struct { + logger *slog.Logger +} + +type ClientConfigure func(*Client) + +func NewClient(configures ...ClientConfigure) *Client { + client := &Client{} + for _, configure := range configures { + configure(client) + } + return client +} + +func WithLogger(logger *slog.Logger) ClientConfigure { + return func(c *Client) { + c.logger = logger + } +} diff --git a/internal/notify/client_notifier.go b/internal/notify/client_notifier.go new file mode 100644 index 000000000..5611245b7 --- /dev/null +++ b/internal/notify/client_notifier.go @@ -0,0 +1,49 @@ +package notify + +import ( + "context" + "errors" + "fmt" + + "github.com/certimate-go/certimate/internal/domain" + "github.com/certimate-go/certimate/internal/notify/notifiers" +) + +type SendNotificationRequest struct { + // 提供商相关 + Provider string + ProviderAccessConfig map[string]any + ProviderExtendedConfig map[string]any + + // 通知相关 + Subject string + Message string +} + +type SendNotificationResponse struct{} + +func (c *Client) SendNotification(ctx context.Context, request *SendNotificationRequest) (*SendNotificationResponse, error) { + if request == nil { + return nil, errors.New("the request is nil") + } + + providerFactory, err := notifiers.Registries.Get(domain.NotificationProviderType(request.Provider)) + if err != nil { + return nil, err + } + + provider, err := providerFactory(¬ifiers.ProviderFactoryOptions{ + ProviderAccessConfig: request.ProviderAccessConfig, + ProviderExtendedConfig: request.ProviderExtendedConfig, + }) + if err != nil { + return nil, fmt.Errorf("failed to initialize notification provider '%s': %w", request.Provider, err) + } + + provider.SetLogger(c.logger) + if _, err := provider.Notify(ctx, request.Subject, request.Message); err != nil { + return nil, err + } + + return &SendNotificationResponse{}, nil +} diff --git a/internal/notify/notifier.go b/internal/notify/notifier.go deleted file mode 100644 index 410881f14..000000000 --- a/internal/notify/notifier.go +++ /dev/null @@ -1,75 +0,0 @@ -package notify - -import ( - "context" - "fmt" - "log/slog" - - "github.com/certimate-go/certimate/internal/domain" - "github.com/certimate-go/certimate/internal/repository" - "github.com/certimate-go/certimate/pkg/core" -) - -type Notifier interface { - Notify(ctx context.Context) error -} - -type NotifierWithWorkflowNodeConfig struct { - Node *domain.WorkflowNode - Logger *slog.Logger - Subject string - Message string -} - -// TODO: refactor -func NewWithWorkflowNode(config NotifierWithWorkflowNodeConfig) (Notifier, error) { - if config.Node == nil { - return nil, fmt.Errorf("the node is nil") - } - if config.Node.Type != domain.WorkflowNodeTypeBizNotify { - return nil, fmt.Errorf("the node type is '%s', expected '%s'", string(config.Node.Type), string(domain.WorkflowNodeTypeBizNotify)) - } - - nodeCfg := config.Node.Data.Config.AsBizNotify() - options := ¬ifierProviderOptions{ - Provider: domain.NotificationProviderType(nodeCfg.Provider), - ProviderAccessConfig: make(map[string]any), - ProviderExtendedConfig: nodeCfg.ProviderConfig, - } - - accessRepo := repository.NewAccessRepository() - if nodeCfg.ProviderAccessId != "" { - access, err := accessRepo.GetById(context.Background(), nodeCfg.ProviderAccessId) - if err != nil { - return nil, fmt.Errorf("failed to get access #%s record: %w", nodeCfg.ProviderAccessId, err) - } else { - options.ProviderAccessConfig = access.Config - } - } - - notifier, err := createNotifierProvider(options) - if err != nil { - return nil, err - } else { - notifier.SetLogger(config.Logger) - } - - return ¬ifierImpl{ - provider: notifier, - subject: config.Subject, - message: config.Message, - }, nil -} - -type notifierImpl struct { - provider core.Notifier - subject string - message string -} - -var _ Notifier = (*notifierImpl)(nil) - -func (n *notifierImpl) Notify(ctx context.Context) error { - _, err := n.provider.Notify(ctx, n.subject, n.message) - return err -} diff --git a/internal/notify/providers.go b/internal/notify/providers.go deleted file mode 100644 index f2a6bbf01..000000000 --- a/internal/notify/providers.go +++ /dev/null @@ -1,25 +0,0 @@ -package notify - -import ( - "github.com/certimate-go/certimate/internal/domain" - "github.com/certimate-go/certimate/internal/notify/notifiers" - "github.com/certimate-go/certimate/pkg/core" -) - -type notifierProviderOptions struct { - Provider domain.NotificationProviderType - ProviderAccessConfig map[string]any - ProviderExtendedConfig map[string]any -} - -func createNotifierProvider(options *notifierProviderOptions) (core.Notifier, error) { - provider, err := notifiers.Registries.Get(options.Provider) - if err != nil { - return nil, err - } - - return provider(¬ifiers.ProviderFactoryOptions{ - ProviderAccessConfig: options.ProviderAccessConfig, - ProviderExtendedConfig: options.ProviderExtendedConfig, - }) -} diff --git a/internal/notify/service.go b/internal/notify/service.go index b689d9db0..286bab07f 100644 --- a/internal/notify/service.go +++ b/internal/notify/service.go @@ -8,8 +8,8 @@ import ( ) const ( - notifyTestTitle = "[Certimate] Notification Test" - notifyTestBody = "Welcome to use Certimate!" + notifyTestSubject = "[Certimate] Notification Testing" + notifyTestMessage = "Welcome to use Certimate!" ) type NotifyService struct{} diff --git a/internal/workflow/engine/executor_bizapply.go b/internal/workflow/engine/executor_bizapply.go index a69d09ed8..2d6c1921b 100644 --- a/internal/workflow/engine/executor_bizapply.go +++ b/internal/workflow/engine/executor_bizapply.go @@ -343,8 +343,8 @@ func (ne *bizApplyNodeExecutor) executeObtain(execCtx *NodeExecutionContext, nod return nil, err } - // 申请证书 - obtainResp, err := legoClient.ObtainCertificateWithContext(execCtx.ctx, obtainReq) + // 执行申请证书请求 + obtainResp, err := legoClient.ObtainCertificate(execCtx.ctx, obtainReq) if err != nil { ne.logger.Warn("failed to obtain certificate") return nil, err diff --git a/internal/workflow/engine/executor_bizdeploy.go b/internal/workflow/engine/executor_bizdeploy.go index 73dc965be..9f8b98b85 100644 --- a/internal/workflow/engine/executor_bizdeploy.go +++ b/internal/workflow/engine/executor_bizdeploy.go @@ -6,7 +6,7 @@ import ( "maps" "strings" - "github.com/certimate-go/certimate/internal/deployer" + "github.com/certimate-go/certimate/internal/certdeploy" "github.com/certimate-go/certimate/internal/domain" "github.com/certimate-go/certimate/internal/repository" ) @@ -18,6 +18,7 @@ import ( type bizDeployNodeExecutor struct { nodeExecutor + accessRepo accessRepository certificateRepo certificateRepository wfoutputRepo workflowOutputRepository } @@ -66,21 +67,28 @@ func (ne *bizDeployNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeEx } } - // 初始化部署器 - // TODO: 解耦 - deployer, err := deployer.NewWithWorkflowNode(deployer.DeployerWithWorkflowNodeConfig{ - Node: execCtx.Node, - Logger: ne.logger, - CertificatePEM: inputCertificate.Certificate, - PrivateKeyPEM: inputCertificate.PrivateKey, - }) - if err != nil { - ne.logger.Warn("failed to create deployer provider") - return execRes, err + // 读取部署提供商授权 + providerAccessConfig := make(map[string]any) + if nodeCfg.ProviderAccessId != "" { + if access, err := ne.accessRepo.GetById(execCtx.ctx, nodeCfg.ProviderAccessId); err != nil { + return nil, fmt.Errorf("failed to get access #%s record: %w", nodeCfg.ProviderAccessId, err) + } else { + providerAccessConfig = access.Config + } } + // 初始化部署器 + deployClient := certdeploy.NewClient(certdeploy.WithLogger(ne.logger)) + // 部署证书 - if err := deployer.Deploy(execCtx.ctx); err != nil { + deployReq := &certdeploy.DeployCertificateRequest{ + Provider: nodeCfg.Provider, + ProviderAccessConfig: providerAccessConfig, + ProviderExtendedConfig: nodeCfg.ProviderConfig, + Certificate: inputCertificate.Certificate, + PrivateKey: inputCertificate.PrivateKey, + } + if _, err := deployClient.DeployCertificate(execCtx.ctx, deployReq); err != nil { ne.logger.Warn("failed to deploy certificate") return execRes, err } @@ -127,6 +135,7 @@ func (ne *bizDeployNodeExecutor) checkCanSkip(execCtx *NodeExecutionContext, las func newBizDeployNodeExecutor() NodeExecutor { return &bizDeployNodeExecutor{ nodeExecutor: nodeExecutor{logger: slog.Default()}, + accessRepo: repository.NewAccessRepository(), certificateRepo: repository.NewCertificateRepository(), wfoutputRepo: repository.NewWorkflowOutputRepository(), } diff --git a/internal/workflow/engine/executor_biznotify.go b/internal/workflow/engine/executor_biznotify.go index c15c74ce5..e1410faeb 100644 --- a/internal/workflow/engine/executor_biznotify.go +++ b/internal/workflow/engine/executor_biznotify.go @@ -11,6 +11,7 @@ import ( type bizNotifyNodeExecutor struct { nodeExecutor + accessRepo accessRepository settingsRepo settingsRepository } @@ -26,20 +27,28 @@ func (ne *bizNotifyNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeEx return execRes, nil } - // 初始化通知器 - deployer, err := notify.NewWithWorkflowNode(notify.NotifierWithWorkflowNodeConfig{ - Node: execCtx.Node, - Logger: ne.logger, - Subject: nodeCfg.Subject, - Message: nodeCfg.Message, - }) - if err != nil { - ne.logger.Warn("failed to create notifier provider") - return execRes, err + // 读取部署提供商授权 + providerAccessConfig := make(map[string]any) + if nodeCfg.ProviderAccessId != "" { + if access, err := ne.accessRepo.GetById(execCtx.ctx, nodeCfg.ProviderAccessId); err != nil { + return nil, fmt.Errorf("failed to get access #%s record: %w", nodeCfg.ProviderAccessId, err) + } else { + providerAccessConfig = access.Config + } } + // 初始化通知器 + notifyClient := notify.NewClient(notify.WithLogger(ne.logger)) + // 推送通知 - if err := deployer.Notify(execCtx.ctx); err != nil { + notifyReq := ¬ify.SendNotificationRequest{ + Provider: nodeCfg.Provider, + ProviderAccessConfig: providerAccessConfig, + ProviderExtendedConfig: nodeCfg.ProviderConfig, + Subject: nodeCfg.Subject, + Message: nodeCfg.Message, + } + if _, err := notifyClient.SendNotification(execCtx.ctx, notifyReq); err != nil { ne.logger.Warn("failed to send notification") return execRes, err } @@ -69,6 +78,7 @@ func (ne *bizNotifyNodeExecutor) checkCanSkip(execCtx *NodeExecutionContext) (_s func newBizNotifyNodeExecutor() NodeExecutor { return &bizNotifyNodeExecutor{ nodeExecutor: nodeExecutor{logger: slog.Default()}, + accessRepo: repository.NewAccessRepository(), settingsRepo: repository.NewSettingsRepository(), } } diff --git a/pkg/core/ssl-applicator/acme-http01/providers/local/local.go b/pkg/core/ssl-applicator/acme-http01/providers/local/local.go index 53a98167f..50fa2d57b 100644 --- a/pkg/core/ssl-applicator/acme-http01/providers/local/local.go +++ b/pkg/core/ssl-applicator/acme-http01/providers/local/local.go @@ -9,6 +9,7 @@ import ( ) type ChallengeProviderConfig struct { + // 网站根目录路径。 WebRootPath string `json:"webRootPath"` } diff --git a/pkg/core/ssl-applicator/acme-http01/providers/ssh/ssh.go b/pkg/core/ssl-applicator/acme-http01/providers/ssh/ssh.go new file mode 100644 index 000000000..9863d6aa7 --- /dev/null +++ b/pkg/core/ssl-applicator/acme-http01/providers/ssh/ssh.go @@ -0,0 +1,292 @@ +package ssh + +import ( + "errors" + "fmt" + "net" + "path/filepath" + "strconv" + "strings" + + "github.com/go-acme/lego/v4/challenge/http01" + "golang.org/x/crypto/ssh" + + "github.com/certimate-go/certimate/pkg/core" + xssh "github.com/certimate-go/certimate/pkg/utils/ssh" +) + +type ServerConfig struct { + // SSH 主机。 + // 零值时默认值 "localhost"。 + SshHost string `json:"sshHost,omitempty"` + // SSH 端口。 + // 零值时默认值 22。 + SshPort int32 `json:"sshPort,omitempty"` + // SSH 认证方式。 + // 可取值 "none"、"password"、"key"。 + // 零值时根据有无密码或私钥字段决定。 + SshAuthMethod string `json:"sshAuthMethod,omitempty"` + // SSH 登录用户名。 + // 零值时默认值 "root"。 + SshUsername string `json:"sshUsername,omitempty"` + // SSH 登录密码。 + SshPassword string `json:"sshPassword,omitempty"` + // SSH 登录私钥。 + SshKey string `json:"sshKey,omitempty"` + // SSH 登录私钥口令。 + SshKeyPassphrase string `json:"sshKeyPassphrase,omitempty"` +} + +type ChallengeProviderConfig struct { + ServerConfig + + // 跳板机配置数组。 + JumpServers []ServerConfig `json:"jumpServers,omitempty"` + // 是否回退使用 SCP。 + UseSCP bool `json:"useSCP,omitempty"` + // 网站根目录路径。 + WebRootPath string `json:"webRootPath"` +} + +func NewChallengeProvider(config *ChallengeProviderConfig) (core.ACMEChallenger, error) { + if config == nil { + return nil, errors.New("the configuration of the acme challenge provider is nil") + } + + provider := &provider{config: config} + return provider, nil +} + +type provider struct { + config *ChallengeProviderConfig +} + +func (p *provider) Present(domain, token, keyAuth string) error { + var err error + + // 创建 TCP 链接 + var targetConn net.Conn + if len(p.config.JumpServers) > 0 { + var jumpClient *ssh.Client + for i, jumpServerConf := range p.config.JumpServers { + var jumpConn net.Conn + // 第一个连接是主机发起,后续通过跳板机发起 + if jumpClient == nil { + jumpConn, err = net.Dial("tcp", net.JoinHostPort(jumpServerConf.SshHost, strconv.Itoa(int(jumpServerConf.SshPort)))) + } else { + jumpConn, err = jumpClient.Dial("tcp", net.JoinHostPort(jumpServerConf.SshHost, strconv.Itoa(int(jumpServerConf.SshPort)))) + } + if err != nil { + return fmt.Errorf("failed to connect to jump server [%d]: %w", i+1, err) + } + defer jumpConn.Close() + + newClient, err := p.createSshClient( + jumpConn, + jumpServerConf.SshHost, + jumpServerConf.SshPort, + jumpServerConf.SshAuthMethod, + jumpServerConf.SshUsername, + jumpServerConf.SshPassword, + jumpServerConf.SshKey, + jumpServerConf.SshKeyPassphrase, + ) + if err != nil { + return fmt.Errorf("failed to create jump server ssh client[%d]: %w", i+1, err) + } + defer newClient.Close() + + jumpClient = newClient + } + + // 通过跳板机发起 TCP 连接到目标服务器 + targetConn, err = jumpClient.Dial("tcp", net.JoinHostPort(p.config.SshHost, strconv.Itoa(int(p.config.SshPort)))) + if err != nil { + return fmt.Errorf("failed to connect to target server: %w", err) + } + } else { + // 直接发起 TCP 连接到目标服务器 + targetConn, err = net.Dial("tcp", net.JoinHostPort(p.config.SshHost, strconv.Itoa(int(p.config.SshPort)))) + if err != nil { + return fmt.Errorf("failed to connect to target server: %w", err) + } + } + defer targetConn.Close() + + // 创建 SSH 客户端 + client, err := p.createSshClient( + targetConn, + p.config.SshHost, + p.config.SshPort, + p.config.SshAuthMethod, + p.config.SshUsername, + p.config.SshPassword, + p.config.SshKey, + p.config.SshKeyPassphrase, + ) + if err != nil { + return fmt.Errorf("failed to create ssh client: %w", err) + } + defer client.Close() + + // 写入质询文件 + challengeFilePath := filepath.Join(p.config.WebRootPath, http01.ChallengePath(token)) + if err := xssh.WriteRemoteString(client, challengeFilePath, keyAuth, p.config.UseSCP); err != nil { + return fmt.Errorf("failed to write file in webroot for HTTP challenge: %w", err) + } + + return nil +} + +func (p *provider) CleanUp(domain, token, keyAuth string) error { + var err error + + // 创建 TCP 链接 + var targetConn net.Conn + if len(p.config.JumpServers) > 0 { + var jumpClient *ssh.Client + for i, jumpServerConf := range p.config.JumpServers { + var jumpConn net.Conn + // 第一个连接是主机发起,后续通过跳板机发起 + if jumpClient == nil { + jumpConn, err = net.Dial("tcp", net.JoinHostPort(jumpServerConf.SshHost, strconv.Itoa(int(jumpServerConf.SshPort)))) + } else { + jumpConn, err = jumpClient.Dial("tcp", net.JoinHostPort(jumpServerConf.SshHost, strconv.Itoa(int(jumpServerConf.SshPort)))) + } + if err != nil { + return fmt.Errorf("failed to connect to jump server [%d]: %w", i+1, err) + } + defer jumpConn.Close() + + newClient, err := p.createSshClient( + jumpConn, + jumpServerConf.SshHost, + jumpServerConf.SshPort, + jumpServerConf.SshAuthMethod, + jumpServerConf.SshUsername, + jumpServerConf.SshPassword, + jumpServerConf.SshKey, + jumpServerConf.SshKeyPassphrase, + ) + if err != nil { + return fmt.Errorf("failed to create jump server ssh client[%d]: %w", i+1, err) + } + defer newClient.Close() + + jumpClient = newClient + } + + // 通过跳板机发起 TCP 连接到目标服务器 + targetConn, err = jumpClient.Dial("tcp", net.JoinHostPort(p.config.SshHost, strconv.Itoa(int(p.config.SshPort)))) + if err != nil { + return fmt.Errorf("failed to connect to target server: %w", err) + } + } else { + // 直接发起 TCP 连接到目标服务器 + targetConn, err = net.Dial("tcp", net.JoinHostPort(p.config.SshHost, strconv.Itoa(int(p.config.SshPort)))) + if err != nil { + return fmt.Errorf("failed to connect to target server: %w", err) + } + } + defer targetConn.Close() + + // 创建 SSH 客户端 + client, err := p.createSshClient( + targetConn, + p.config.SshHost, + p.config.SshPort, + p.config.SshAuthMethod, + p.config.SshUsername, + p.config.SshPassword, + p.config.SshKey, + p.config.SshKeyPassphrase, + ) + if err != nil { + return fmt.Errorf("failed to create ssh client: %w", err) + } + defer client.Close() + + // 删除质询文件 + challengeFilePath := filepath.Join(p.config.WebRootPath, http01.ChallengePath(token)) + xssh.RemoveRemote(client, challengeFilePath, p.config.UseSCP) + + return nil +} + +func (p *provider) createSshClient(conn net.Conn, host string, port int32, authMethod string, username, password, key, keyPassphrase string) (*ssh.Client, error) { + if host == "" { + host = "localhost" + } + + if port == 0 { + port = 22 + } + + if username == "" { + username = "root" + } + + const AUTH_METHOD_NONE = "none" + const AUTH_METHOD_PASSWORD = "password" + const AUTH_METHOD_KEY = "key" + if authMethod == "" { + if key != "" { + authMethod = AUTH_METHOD_KEY + } else if password != "" { + authMethod = AUTH_METHOD_PASSWORD + } else { + authMethod = AUTH_METHOD_NONE + } + } + + authentications := make([]ssh.AuthMethod, 0) + switch authMethod { + case AUTH_METHOD_NONE: + { + } + + case AUTH_METHOD_PASSWORD: + { + authentications = append(authentications, ssh.Password(password)) + authentications = append(authentications, ssh.KeyboardInteractive(func(user, instruction string, questions []string, echos []bool) ([]string, error) { + if len(questions) == 1 { + return []string{password}, nil + } + return nil, fmt.Errorf("unexpected keyboard interactive question [%s]", strings.Join(questions, ", ")) + })) + } + + case AUTH_METHOD_KEY: + { + var signer ssh.Signer + var err error + + if keyPassphrase != "" { + signer, err = ssh.ParsePrivateKeyWithPassphrase([]byte(key), []byte(keyPassphrase)) + } else { + signer, err = ssh.ParsePrivateKey([]byte(key)) + } + + if err != nil { + return nil, err + } + + authentications = append(authentications, ssh.PublicKeys(signer)) + } + + default: + return nil, fmt.Errorf("unsupported auth method '%s'", authMethod) + } + + addr := net.JoinHostPort(host, strconv.Itoa(int(port))) + sshConn, chans, reqs, err := ssh.NewClientConn(conn, addr, &ssh.ClientConfig{ + User: username, + Auth: authentications, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + }) + if err != nil { + return nil, err + } + + return ssh.NewClient(sshConn, chans, reqs), nil +} diff --git a/pkg/core/ssl-deployer/providers/ssh/ssh.go b/pkg/core/ssl-deployer/providers/ssh/ssh.go index 7cbe7c8f3..666cc6c79 100644 --- a/pkg/core/ssl-deployer/providers/ssh/ssh.go +++ b/pkg/core/ssl-deployer/providers/ssh/ssh.go @@ -7,20 +7,17 @@ import ( "fmt" "log/slog" "net" - "os" - "path/filepath" "strconv" "strings" - "github.com/pkg/sftp" - "github.com/povsister/scp" "golang.org/x/crypto/ssh" "github.com/certimate-go/certimate/pkg/core" xcert "github.com/certimate-go/certimate/pkg/utils/cert" + xssh "github.com/certimate-go/certimate/pkg/utils/ssh" ) -type JumpServerConfig struct { +type ServerConfig struct { // SSH 主机。 // 零值时默认值 "localhost"。 SshHost string `json:"sshHost,omitempty"` @@ -43,27 +40,10 @@ type JumpServerConfig struct { } type SSLDeployerProviderConfig struct { - // SSH 主机。 - // 零值时默认值 "localhost"。 - SshHost string `json:"sshHost,omitempty"` - // SSH 端口。 - // 零值时默认值 22。 - SshPort int32 `json:"sshPort,omitempty"` - // SSH 认证方式。 - // 可取值 "none"、"password" 或 "key"。 - // 零值时根据有无密码或私钥字段决定。 - SshAuthMethod string `json:"sshAuthMethod,omitempty"` - // SSH 登录用户名。 - // 零值时默认值 "root"。 - SshUsername string `json:"sshUsername,omitempty"` - // SSH 登录密码。 - SshPassword string `json:"sshPassword,omitempty"` - // SSH 登录私钥。 - SshKey string `json:"sshKey,omitempty"` - // SSH 登录私钥口令。 - SshKeyPassphrase string `json:"sshKeyPassphrase,omitempty"` + ServerConfig + // 跳板机配置数组。 - JumpServers []JumpServerConfig `json:"jumpServers,omitempty"` + JumpServers []ServerConfig `json:"jumpServers,omitempty"` // 是否回退使用 SCP。 UseSCP bool `json:"useSCP,omitempty"` // 前置命令。 @@ -123,15 +103,16 @@ func (d *SSLDeployerProvider) SetLogger(logger *slog.Logger) { } func (d *SSLDeployerProvider) Deploy(ctx context.Context, certPEM string, privkeyPEM string) (*core.SSLDeployResult, error) { + var err error + // 提取服务器证书和中间证书 serverCertPEM, intermediaCertPEM, err := xcert.ExtractCertificatesFromPEM(certPEM) if err != nil { return nil, fmt.Errorf("failed to extract certs: %w", err) } + // 创建 TCP 链接 var targetConn net.Conn - - // 连接到跳板机 if len(d.config.JumpServers) > 0 { var jumpClient *ssh.Client for i, jumpServerConf := range d.config.JumpServers { @@ -182,7 +163,7 @@ func (d *SSLDeployerProvider) Deploy(ctx context.Context, certPEM string, privke } defer targetConn.Close() - // 通过已有的连接创建目标服务器 SSH 客户端 + // 创建 SSH 客户端 client, err := createSshClient( targetConn, d.config.SshHost, @@ -197,7 +178,6 @@ func (d *SSLDeployerProvider) Deploy(ctx context.Context, certPEM string, privke return nil, fmt.Errorf("failed to create ssh client: %w", err) } defer client.Close() - d.logger.Info("ssh connected") // 执行前置命令 @@ -212,26 +192,26 @@ func (d *SSLDeployerProvider) Deploy(ctx context.Context, certPEM string, privke // 上传证书和私钥文件 switch d.config.OutputFormat { case OUTPUT_FORMAT_PEM: - if err := writeFileString(client, d.config.UseSCP, d.config.OutputCertPath, certPEM); err != nil { + if err := xssh.WriteRemoteString(client, 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)) if d.config.OutputServerCertPath != "" { - if err := writeFileString(client, d.config.UseSCP, d.config.OutputServerCertPath, serverCertPEM); err != nil { + if err := xssh.WriteRemoteString(client, d.config.OutputServerCertPath, serverCertPEM, d.config.UseSCP); err != nil { return nil, fmt.Errorf("failed to save 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 := writeFileString(client, d.config.UseSCP, d.config.OutputIntermediaCertPath, intermediaCertPEM); err != nil { + if err := xssh.WriteRemoteString(client, d.config.OutputIntermediaCertPath, intermediaCertPEM, d.config.UseSCP); err != nil { return nil, fmt.Errorf("failed to save intermedia certificate file: %w", err) } d.logger.Info("ssl intermedia certificate file uploaded", slog.String("path", d.config.OutputIntermediaCertPath)) } - if err := writeFileString(client, d.config.UseSCP, d.config.OutputKeyPath, privkeyPEM); err != nil { + if err := xssh.WriteRemoteString(client, 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)) @@ -243,7 +223,7 @@ func (d *SSLDeployerProvider) Deploy(ctx context.Context, certPEM string, privke } d.logger.Info("ssl certificate transformed to pfx") - if err := writeFile(client, d.config.UseSCP, d.config.OutputCertPath, pfxData); err != nil { + if err := xssh.WriteRemote(client, 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)) @@ -255,7 +235,7 @@ func (d *SSLDeployerProvider) Deploy(ctx context.Context, certPEM string, privke } d.logger.Info("ssl certificate transformed to jks") - if err := writeFile(client, d.config.UseSCP, d.config.OutputCertPath, jksData); err != nil { + if err := xssh.WriteRemote(client, 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)) @@ -372,67 +352,3 @@ func execSshCommand(sshCli *ssh.Client, command string) (string, string, error) return stdoutBuf.String(), stderrBuf.String(), nil } - -func writeFileString(sshCli *ssh.Client, useSCP bool, path string, content string) error { - if useSCP { - return writeFileStringWithSCP(sshCli, path, content) - } - - return writeFileStringWithSFTP(sshCli, path, content) -} - -func writeFile(sshCli *ssh.Client, useSCP bool, path string, data []byte) error { - if useSCP { - return writeFileWithSCP(sshCli, path, data) - } - - return writeFileWithSFTP(sshCli, path, data) -} - -func writeFileStringWithSCP(sshCli *ssh.Client, path string, content string) error { - return writeFileWithSCP(sshCli, path, []byte(content)) -} - -func writeFileWithSCP(sshCli *ssh.Client, path string, data []byte) error { - scpCli, err := scp.NewClientFromExistingSSH(sshCli, &scp.ClientOption{}) - if err != nil { - return fmt.Errorf("failed to create scp client: %w", err) - } - - reader := bytes.NewReader(data) - err = scpCli.CopyToRemote(reader, path, &scp.FileTransferOption{}) - if err != nil { - return fmt.Errorf("failed to write to remote file: %w", err) - } - - return nil -} - -func writeFileStringWithSFTP(sshCli *ssh.Client, path string, content string) error { - return writeFileWithSFTP(sshCli, path, []byte(content)) -} - -func writeFileWithSFTP(sshCli *ssh.Client, path string, data []byte) error { - sftpCli, err := sftp.NewClient(sshCli) - if err != nil { - return fmt.Errorf("failed to create sftp client: %w", err) - } - defer sftpCli.Close() - - if err := sftpCli.MkdirAll(filepath.ToSlash(filepath.Dir(path))); err != nil { - return fmt.Errorf("failed to create remote directory: %w", err) - } - - file, err := sftpCli.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC) - if err != nil { - return fmt.Errorf("failed to open remote file: %w", err) - } - defer file.Close() - - _, err = file.Write(data) - if err != nil { - return fmt.Errorf("failed to write to remote file: %w", err) - } - - return nil -} diff --git a/pkg/utils/ssh/io.go b/pkg/utils/ssh/io.go new file mode 100644 index 000000000..7261e58fa --- /dev/null +++ b/pkg/utils/ssh/io.go @@ -0,0 +1,134 @@ +package ssh + +import ( + "bytes" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/pkg/sftp" + "github.com/povsister/scp" + "golang.org/x/crypto/ssh" +) + +// 与 [WriteRemote] 类似,但写入的是字符串内容。 +// +// 入参: +// - sshCli: SSH 客户端。 +// - path: 文件远程路径。 +// - data: 文件数据字节数组。 +// - useSCP: 是否使用 SCP 进行传输,否则使用 SFTP。 +// +// 出参: +// - 错误。 +func WriteRemoteString(sshCli *ssh.Client, path string, content string, useSCP bool) error { + if useSCP { + return writeRemoteStringWithSCP(sshCli, path, content) + } + + return writeRemoteStringWithSFTP(sshCli, path, content) +} + +// 将数据写入指定远程路径的文件。 +// 如果目录不存在,将会递归创建目录。 +// 如果文件不存在,将会创建该文件;如果文件已存在,将会覆盖原有内容。 +// +// 入参: +// - sshCli: SSH 客户端。 +// - path: 文件远程路径。 +// - data: 文件数据字节数组。 +// - useSCP: 是否使用 SCP 进行传输,否则使用 SFTP。 +// +// 出参: +// - 错误。 +func WriteRemote(sshCli *ssh.Client, path string, data []byte, useSCP bool) error { + if useSCP { + return writeRemoteWithSCP(sshCli, path, data) + } + + return writeRemoteWithSFTP(sshCli, path, data) +} + +// 删除指定远程路径的文件。 +// +// 入参: +// - sshCli: SSH 客户端。 +// - path: 文件远程路径。 +// - useSCP: 是否使用 SCP 进行传输,否则使用 SFTP。 +// +// 出参: +// - 错误。 +func RemoveRemote(sshCli *ssh.Client, path string, useSCP bool) error { + if useSCP { + return errors.ErrUnsupported + } + + return removeRemoteWithSFTP(sshCli, path) +} + +func writeRemoteStringWithSCP(sshCli *ssh.Client, path string, content string) error { + return writeRemoteWithSCP(sshCli, path, []byte(content)) +} + +func writeRemoteStringWithSFTP(sshCli *ssh.Client, path string, content string) error { + return writeRemoteWithSFTP(sshCli, path, []byte(content)) +} + +func writeRemoteWithSCP(sshCli *ssh.Client, path string, data []byte) error { + scpCli, err := scp.NewClientFromExistingSSH(sshCli, &scp.ClientOption{}) + if err != nil { + return fmt.Errorf("failed to create scp client: %w", err) + } + + reader := bytes.NewReader(data) + err = scpCli.CopyToRemote(reader, path, &scp.FileTransferOption{}) + if err != nil { + return fmt.Errorf("failed to write to remote file: %w", err) + } + + return nil +} + +func writeRemoteWithSFTP(sshCli *ssh.Client, path string, data []byte) error { + sftpCli, err := sftp.NewClient(sshCli) + if err != nil { + return fmt.Errorf("failed to create sftp client: %w", err) + } + defer sftpCli.Close() + + if err := sftpCli.MkdirAll(filepath.ToSlash(filepath.Dir(path))); err != nil { + return fmt.Errorf("failed to create remote directory: %w", err) + } + + file, err := sftpCli.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC) + if err != nil { + return fmt.Errorf("failed to open remote file: %w", err) + } + defer file.Close() + + _, err = file.Write(data) + if err != nil { + return fmt.Errorf("failed to write to remote file: %w", err) + } + + return nil +} + +func removeRemoteWithSFTP(sshCli *ssh.Client, path string) error { + sftpCli, err := sftp.NewClient(sshCli) + if err != nil { + return fmt.Errorf("failed to create sftp client: %w", err) + } + defer sftpCli.Close() + + if err := sftpCli.MkdirAll(filepath.ToSlash(filepath.Dir(path))); err != nil { + return fmt.Errorf("failed to create remote directory: %w", err) + } + + if err := sftpCli.Remove(path); err != nil { + return fmt.Errorf("failed to remove remote file: %w", err) + } + + return nil +} diff --git a/ui/src/components/MultipleSplitValueInput.tsx b/ui/src/components/MultipleSplitValueInput.tsx index d161c8474..e09082e2c 100644 --- a/ui/src/components/MultipleSplitValueInput.tsx +++ b/ui/src/components/MultipleSplitValueInput.tsx @@ -56,7 +56,7 @@ const MultipleSplitValueInput = ({ name: "componentMultipleSplitValueInput_" + nanoid(), initialValues: { value: value?.split(delimiter) }, onSubmit: (values) => { - const temp = values.value ?? []; + const temp = (values.value ?? []) as string[]; if (splitOptions.trimSpace) { temp.map((e) => e.trim()); } diff --git a/ui/src/components/access/AccessEditDrawer.tsx b/ui/src/components/access/AccessEditDrawer.tsx index 29c02c12f..df5a2c26d 100644 --- a/ui/src/components/access/AccessEditDrawer.tsx +++ b/ui/src/components/access/AccessEditDrawer.tsx @@ -146,6 +146,7 @@ const AccessEditDrawer = ({ afterClose, afterSubmit, mode, data, loading, trigge gap="large" placeholder={t("access.form.provider.search.placeholder")} showOptionTags={usage == null || (usage === "dns-hosting" ? { [ACCESS_USAGES.DNS]: true, [ACCESS_USAGES.HOSTING]: true } : false)} + showSearch onFilter={providerFilter} onSelect={handleProviderPick} /> diff --git a/ui/src/components/access/AccessForm.tsx b/ui/src/components/access/AccessForm.tsx index 742782a87..976434ae7 100644 --- a/ui/src/components/access/AccessForm.tsx +++ b/ui/src/components/access/AccessForm.tsx @@ -112,7 +112,7 @@ const AccessForm = ({ className, style, disabled, initialValues, mode, usage, .. reserve: z.string().nullish(), }); const formRule = createSchemaFieldRule(formSchema); - const { form: formInst, formProps } = useAntdForm({ + const { form: formInst, formProps } = useAntdForm>({ form: props.form, name: "accessForm", initialValues: initialValues, diff --git a/ui/src/components/access/forms/AccessConfigFieldsProviderSSH.tsx b/ui/src/components/access/forms/AccessConfigFieldsProviderSSH.tsx index 3f32228a7..d111f38c8 100644 --- a/ui/src/components/access/forms/AccessConfigFieldsProviderSSH.tsx +++ b/ui/src/components/access/forms/AccessConfigFieldsProviderSSH.tsx @@ -50,13 +50,11 @@ const AccessConfigFormFieldsProviderSSH = ({ disabled }: { disabled?: boolean }) label={t("access.form.ssh_auth_method.label")} rules={[formRule]} > - ({ - key: s, - label: t(`access.form.ssh_auth_method.option.${s}.label`), - value: s, - }))} - /> + + {t("access.form.ssh_auth_method.option.none.label")} + {t("access.form.ssh_auth_method.option.password.label")} + {t("access.form.ssh_auth_method.option.key.label")} + diff --git a/ui/src/components/provider/ACMEDns01ProviderSelect.tsx b/ui/src/components/provider/ACMEDns01ProviderSelect.tsx index 1861a847b..0f5179c31 100644 --- a/ui/src/components/provider/ACMEDns01ProviderSelect.tsx +++ b/ui/src/components/provider/ACMEDns01ProviderSelect.tsx @@ -1,35 +1,47 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { Avatar, Select, type SelectProps, Typography, theme } from "antd"; +import { Avatar, Select, Typography, theme } from "antd"; import { type ACMEDns01Provider, acmeDns01ProvidersMap } from "@/domain/provider"; -export interface ACMEDns01ProviderSelectProps - extends Omit { - onFilter?: (value: string, option: ACMEDns01Provider) => boolean; +import { type SharedSelectProps, useSelectDataSource } from "./_shared"; + +export interface ACMEDns01ProviderSelectProps extends SharedSelectProps { + showAvailability?: boolean; } -const ACMEDns01ProviderSelect = ({ onFilter, ...props }: ACMEDns01ProviderSelectProps) => { +const ACMEDns01ProviderSelect = ({ showAvailability, onFilter, ...props }: ACMEDns01ProviderSelectProps) => { const { t } = useTranslation(); const { token: themeToken } = theme.useToken(); - const options = useMemo>(() => { - return Array.from(acmeDns01ProvidersMap.values()) - .filter((provider) => { - if (onFilter) { - return onFilter(provider.type, provider); - } - - return true; - }) - .map((provider) => ({ + const dataSources = useSelectDataSource({ + dataSource: Array.from(acmeDns01ProvidersMap.values()), + filters: [onFilter!], + }); + const options = useMemo(() => { + const convert = (providers: ACMEDns01Provider[]): Array<{ key: string; value: string; label: string; data: ACMEDns01Provider }> => { + return providers.map((provider) => ({ key: provider.type, value: provider.type, label: t(provider.name), data: provider, })); - }, [onFilter]); + }; + + return showAvailability + ? [ + { + label: t("provider.text.available_group"), + options: convert(dataSources.available), + }, + { + label: t("provider.text.unavailable_group"), + options: convert(dataSources.unavailable), + }, + ].filter((group) => group.options.length > 0) + : convert(dataSources.filtered); + }, [showAvailability, dataSources]); const renderOption = (key: string) => { const provider = acmeDns01ProvidersMap.get(key); @@ -46,9 +58,11 @@ const ACMEDns01ProviderSelect = ({ onFilter, ...props }: ACMEDns01ProviderSelect {...props} filterOption={(inputValue, option) => { if (!option) return false; + if (!option.label) return false; + if (!option.value) return false; const value = inputValue.toLowerCase(); - return option.value.toLowerCase().includes(value) || option.label.toLowerCase().includes(value); + return String(option.value).toLowerCase().includes(value) || String(option.label).toLowerCase().includes(value); }} labelRender={({ value }) => { if (value != null) { @@ -60,7 +74,7 @@ const ACMEDns01ProviderSelect = ({ onFilter, ...props }: ACMEDns01ProviderSelect options={options} optionFilterProp={void 0} optionLabelProp={void 0} - optionRender={(option) => renderOption(option.data.value)} + optionRender={(option) => renderOption(option.data.value as string)} /> ); }; diff --git a/ui/src/components/provider/ACMEHttp01ProviderSelect.tsx b/ui/src/components/provider/ACMEHttp01ProviderSelect.tsx new file mode 100644 index 000000000..a91c8c5a2 --- /dev/null +++ b/ui/src/components/provider/ACMEHttp01ProviderSelect.tsx @@ -0,0 +1,82 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { Avatar, Select, Typography, theme } from "antd"; + +import { type ACMEHttp01Provider, acmeHttp01ProvidersMap } from "@/domain/provider"; + +import { type SharedSelectProps, useSelectDataSource } from "./_shared"; + +export interface ACMEHttp01ProviderSelectProps extends SharedSelectProps { + showAvailability?: boolean; +} + +const ACMEHttp01ProviderSelect = ({ showAvailability, onFilter, ...props }: ACMEHttp01ProviderSelectProps) => { + const { t } = useTranslation(); + + const { token: themeToken } = theme.useToken(); + + const dataSources = useSelectDataSource({ + dataSource: Array.from(acmeHttp01ProvidersMap.values()), + filters: [onFilter!], + }); + const options = useMemo(() => { + const convert = (providers: ACMEHttp01Provider[]): Array<{ key: string; value: string; label: string; data: ACMEHttp01Provider }> => { + return providers.map((provider) => ({ + key: provider.type, + value: provider.type, + label: t(provider.name), + data: provider, + })); + }; + + return showAvailability + ? [ + { + label: t("provider.text.available_group"), + options: convert(dataSources.available), + }, + { + label: t("provider.text.unavailable_group"), + options: convert(dataSources.unavailable), + }, + ].filter((group) => group.options.length > 0) + : convert(dataSources.filtered); + }, [showAvailability, dataSources]); + + const renderOption = (key: string) => { + const provider = acmeHttp01ProvidersMap.get(key); + return ( +
+ + {t(provider?.name ?? "")} +
+ ); + }; + + return ( + +
+ + ); +}; + +const getInitialValues = (): Nullish>> => { + return { + webRootPath: "/var/www/html/", + }; +}; + +const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType }) => { + const { t } = i18n; + + return z.object({ + webRootPath: z.string().nonempty(t("workflow_node.apply.form.local_webroot_path.placeholder")), + }); +}; + +const _default = Object.assign(BizApplyNodeConfigFieldsProviderLocal, { + getInitialValues, + getSchema, +}); + +export default _default; diff --git a/ui/src/components/workflow/designer/forms/BizApplyNodeConfigFieldsProviderSSH.tsx b/ui/src/components/workflow/designer/forms/BizApplyNodeConfigFieldsProviderSSH.tsx new file mode 100644 index 000000000..cad4fa024 --- /dev/null +++ b/ui/src/components/workflow/designer/forms/BizApplyNodeConfigFieldsProviderSSH.tsx @@ -0,0 +1,52 @@ +import { getI18n, useTranslation } from "react-i18next"; +import { Form, Input } from "antd"; +import { createSchemaFieldRule } from "antd-zod"; +import { z } from "zod"; + +import { useFormNestedFieldsContext } from "./_context"; + +const BizApplyNodeConfigFieldsProviderSSH = () => { + 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 { + webRootPath: "/var/www/html/", + }; +}; + +const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType }) => { + const { t } = i18n; + + return z.object({ + webRootPath: z.string().nonempty(t("workflow_node.apply.form.ssh_webroot_path.placeholder")), + }); +}; + +const _default = Object.assign(BizApplyNodeConfigFieldsProviderSSH, { + getInitialValues, + getSchema, +}); + +export default _default; diff --git a/ui/src/components/workflow/designer/forms/BizApplyNodeConfigForm.tsx b/ui/src/components/workflow/designer/forms/BizApplyNodeConfigForm.tsx index 423d96f64..5ab9cb563 100644 --- a/ui/src/components/workflow/designer/forms/BizApplyNodeConfigForm.tsx +++ b/ui/src/components/workflow/designer/forms/BizApplyNodeConfigForm.tsx @@ -4,7 +4,22 @@ import { Link } from "react-router"; import { type FlowNodeEntity, getNodeForm } from "@flowgram.ai/fixed-layout-editor"; import { IconChevronRight, IconCircleMinus, IconPlus } from "@tabler/icons-react"; import { useControllableValue, useMount } from "ahooks"; -import { type AnchorProps, AutoComplete, Button, Divider, Flex, Form, type FormInstance, Input, InputNumber, Select, Space, Switch, Typography } from "antd"; +import { + type AnchorProps, + AutoComplete, + Button, + Divider, + Flex, + Form, + type FormInstance, + Input, + InputNumber, + Radio, + Select, + Space, + Switch, + Typography, +} from "antd"; import { createSchemaFieldRule } from "antd-zod"; import { z } from "zod"; @@ -12,9 +27,11 @@ import AccessEditDrawer from "@/components/access/AccessEditDrawer"; import AccessSelect from "@/components/access/AccessSelect"; import MultipleSplitValueInput from "@/components/MultipleSplitValueInput"; import ACMEDns01ProviderSelect from "@/components/provider/ACMEDns01ProviderSelect"; +import ACMEHttp01ProviderSelect from "@/components/provider/ACMEHttp01ProviderSelect"; import CAProviderSelect from "@/components/provider/CAProviderSelect"; import Show from "@/components/Show"; -import { ACCESS_USAGES, ACME_DNS01_PROVIDERS, accessProvidersMap, acmeDns01ProvidersMap, caProvidersMap } from "@/domain/provider"; +import { type AccessModel } from "@/domain/access"; +import { ACME_DNS01_PROVIDERS, ACME_HTTP01_PROVIDERS, acmeDns01ProvidersMap, acmeHttp01ProvidersMap, caProvidersMap } from "@/domain/provider"; import { type WorkflowNodeConfigForBizApply, defaultNodeConfigForBizApply } from "@/domain/workflow"; import { useAntdForm, useZustandShallowSelector } from "@/hooks"; import { useAccessesStore } from "@/stores/access"; @@ -26,11 +43,16 @@ import BizApplyNodeConfigFieldsProviderAliyunESA from "./BizApplyNodeConfigField import BizApplyNodeConfigFieldsProviderAWSRoute53 from "./BizApplyNodeConfigFieldsProviderAWSRoute53"; import BizApplyNodeConfigFieldsProviderHuaweiCloudDNS from "./BizApplyNodeConfigFieldsProviderHuaweiCloudDNS"; import BizApplyNodeConfigFieldsProviderJDCloudDNS from "./BizApplyNodeConfigFieldsProviderJDCloudDNS"; +import BizApplyNodeConfigFieldsProviderLocal from "./BizApplyNodeConfigFieldsProviderLocal"; +import BizApplyNodeConfigFieldsProviderSSH from "./BizApplyNodeConfigFieldsProviderSSH"; import BizApplyNodeConfigFieldsProviderTencentCloudEO from "./BizApplyNodeConfigFieldsProviderTencentCloudEO"; import { NodeType } from "../nodes/typings"; const MULTIPLE_INPUT_SEPARATOR = ";"; +const CHALLENGE_TYPE_DNS01 = "dns-01"; +const CHALLENGE_TYPE_HTTP01 = "http-01"; + export interface BizApplyNodeConfigFormProps { form: FormInstance; node: FlowNodeEntity; @@ -44,6 +66,16 @@ const BizApplyNodeConfigForm = ({ node, ...props }: BizApplyNodeConfigFormProps) const { i18n, t } = useTranslation(); const { accesses } = useAccessesStore(useZustandShallowSelector("accesses")); + const accessOptionFilter = (_: string, option: AccessModel) => { + if (option.reserve) return false; + if (fieldChallengeType === CHALLENGE_TYPE_DNS01) return acmeDns01ProvidersMap.get(fieldProvider)?.provider === option.provider; + if (fieldChallengeType === CHALLENGE_TYPE_HTTP01) return acmeHttp01ProvidersMap.get(fieldProvider)?.provider === option.provider; + return false; + }; + const accessOptionFilterForCA = (_: string, option: AccessModel) => { + if (option.reserve !== "ca") return false; + return caProvidersMap.get(fieldCAProvider)?.provider === option.provider; + }; const initialValues = useMemo(() => { return getNodeForm(node)?.getValueIn("config") as WorkflowNodeConfigForBizApply | undefined; @@ -51,55 +83,95 @@ const BizApplyNodeConfigForm = ({ node, ...props }: BizApplyNodeConfigFormProps) const formSchema = getSchema({ i18n }); const formRule = createSchemaFieldRule(formSchema); - const { form: formInst, formProps } = useAntdForm({ + const { form: formInst, formProps } = useAntdForm>({ form: props.form, name: "workflowNodeBizApplyConfigForm", initialValues: initialValues ?? getInitialValues(), }); + const fieldChallengeType = Form.useWatch("challengeType", { form: formInst, preserve: true }); const fieldProvider = Form.useWatch("provider", { form: formInst, preserve: true }); const fieldProviderAccessId = Form.useWatch("providerAccessId", { form: formInst, preserve: true }); const fieldCAProvider = Form.useWatch("caProvider", { form: formInst, preserve: true }); + const fieldCAProviderAccessId = Form.useWatch("caProviderAccessId", { form: formInst, preserve: true }); const NestedProviderConfigFields = useMemo(() => { /* 注意:如果追加新的子组件,请保持以 ASCII 排序。 NOTICE: If you add new child component, please keep ASCII order. */ - switch (fieldProvider) { - case ACME_DNS01_PROVIDERS.ALIYUN_ESA: { - return BizApplyNodeConfigFieldsProviderAliyunESA; - } - case ACME_DNS01_PROVIDERS.AWS: - case ACME_DNS01_PROVIDERS.AWS_ROUTE53: { - return BizApplyNodeConfigFieldsProviderAWSRoute53; - } - case ACME_DNS01_PROVIDERS.HUAWEICLOUD: - case ACME_DNS01_PROVIDERS.HUAWEICLOUD_DNS: { - return BizApplyNodeConfigFieldsProviderHuaweiCloudDNS; - } - case ACME_DNS01_PROVIDERS.JDCLOUD: - case ACME_DNS01_PROVIDERS.JDCLOUD_DNS: { - return BizApplyNodeConfigFieldsProviderJDCloudDNS; - } - case ACME_DNS01_PROVIDERS.TENCENTCLOUD_EO: { - return BizApplyNodeConfigFieldsProviderTencentCloudEO; - } - } - }, [fieldProvider]); + switch (fieldChallengeType) { + case CHALLENGE_TYPE_DNS01: + { + switch (fieldProvider) { + case ACME_DNS01_PROVIDERS.ALIYUN_ESA: { + return BizApplyNodeConfigFieldsProviderAliyunESA; + } + case ACME_DNS01_PROVIDERS.AWS: + case ACME_DNS01_PROVIDERS.AWS_ROUTE53: { + return BizApplyNodeConfigFieldsProviderAWSRoute53; + } + case ACME_DNS01_PROVIDERS.HUAWEICLOUD: + case ACME_DNS01_PROVIDERS.HUAWEICLOUD_DNS: { + return BizApplyNodeConfigFieldsProviderHuaweiCloudDNS; + } + case ACME_DNS01_PROVIDERS.JDCLOUD: + case ACME_DNS01_PROVIDERS.JDCLOUD_DNS: { + return BizApplyNodeConfigFieldsProviderJDCloudDNS; + } + case ACME_DNS01_PROVIDERS.TENCENTCLOUD_EO: { + return BizApplyNodeConfigFieldsProviderTencentCloudEO; + } + } + } + break; - const [showProvider, setShowProvider] = useState(false); - useEffect(() => { - // 通常情况下每个授权信息只对应一个 DNS 提供商,此时无需显示 DNS 提供商字段; - // 如果对应多个(如 AWS 的 Route53、Lightsail,阿里云的 DNS、ESA,腾讯云的 DNS、EdgeOne 等),则显示。 - if (fieldProviderAccessId) { - const access = accesses.find((e) => e.id === fieldProviderAccessId); - const providers = Array.from(acmeDns01ProvidersMap.values()).filter((e) => e.provider === access?.provider); - setShowProvider(providers.length > 1); - } else { - setShowProvider(false); + case CHALLENGE_TYPE_HTTP01: + switch (fieldProvider) { + case ACME_HTTP01_PROVIDERS.LOCAL: { + return BizApplyNodeConfigFieldsProviderLocal; + } + case ACME_HTTP01_PROVIDERS.SSH: { + return BizApplyNodeConfigFieldsProviderSSH; + } + } + break; } - }, [accesses, fieldProviderAccessId]); + }, [fieldChallengeType, fieldProvider]); + + const [showProviderAccess, setShowProviderAccess] = useState(false); + useEffect(() => { + // 内置的质询提供商(如本地主机)无需显示授权信息字段 + switch (fieldChallengeType) { + case CHALLENGE_TYPE_DNS01: + { + if (fieldProvider) { + const provider = acmeDns01ProvidersMap.get(fieldProvider); + setShowProviderAccess(!provider?.builtin); + } else { + setShowProviderAccess(false); + } + } + break; + + case CHALLENGE_TYPE_HTTP01: + { + if (fieldProvider) { + const provider = acmeHttp01ProvidersMap.get(fieldProvider); + setShowProviderAccess(!provider?.builtin); + } else { + setShowProviderAccess(false); + } + } + break; + + default: + { + setShowProviderAccess(false); + } + break; + } + }, [fieldChallengeType, fieldProvider]); const [showCAProviderAccess, setShowCAProviderAccess] = useState(false); useEffect(() => { @@ -112,31 +184,92 @@ const BizApplyNodeConfigForm = ({ node, ...props }: BizApplyNodeConfigFormProps) } }, [fieldCAProvider]); - const handleProviderSelect = (value: string) => { - if (fieldProvider === value) return; + useEffect(() => { + // 如果未选择质询提供商,则清空授权信息 + if (!fieldProvider && fieldProviderAccessId) { + formInst.setFieldValue("providerAccessId", void 0); + return; + } - // 切换 DNS 提供商时联动授权信息 - if (initialValues?.provider === value) { - formInst.setFieldValue("providerAccessId", initialValues?.providerAccessId); - } else { - if (acmeDns01ProvidersMap.get(fieldProvider)?.provider !== acmeDns01ProvidersMap.get(value)?.provider) { - formInst.setFieldValue("providerAccessId", void 0); + // 如果已选择质询提供商只有一个授权信息,则自动选择该授权信息 + if (fieldProvider && !fieldProviderAccessId) { + const availableAccesses = accesses + .filter((access) => accessOptionFilter(access.provider, access)) + .filter((access) => { + if (fieldChallengeType === CHALLENGE_TYPE_DNS01) return acmeDns01ProvidersMap.get(fieldProvider)?.provider === access.provider; + if (fieldChallengeType === CHALLENGE_TYPE_HTTP01) return acmeHttp01ProvidersMap.get(fieldProvider)?.provider === access.provider; + return false; + }); + if (availableAccesses.length === 1) { + formInst.setFieldValue("providerAccessId", availableAccesses[0].id); } } + }, [fieldChallengeType, fieldProvider, fieldProviderAccessId]); + + useEffect(() => { + // 如果未选择 CA 提供商,则清空授权信息 + if (!fieldCAProvider && fieldCAProviderAccessId) { + formInst.setFieldValue("caProviderAccessId", void 0); + return; + } + + // 如果已选择 CA 提供商只有一个授权信息,则自动选择该授权信息 + if (fieldCAProvider && !fieldCAProviderAccessId) { + const availableAccesses = accesses + .filter((access) => accessOptionFilterForCA(access.provider, access)) + .filter((access) => caProvidersMap.get(fieldCAProvider)?.provider === access.provider); + if (availableAccesses.length === 1) { + formInst.setFieldValue("caProviderAccessId", availableAccesses[0].id); + } + } + }, [fieldCAProvider, fieldCAProviderAccessId]); + + const handleChallengeTypeChange = (value: string) => { + const resetFieldIfInvalid = (field: keyof z.infer) => { + const fieldSchame = formSchema.pick({ [field]: true }); + const fieldValue = formInst.getFieldValue(field); + if (!fieldSchame.safeParse({ [field]: fieldValue }).success) { + formInst.setFieldValue(field, void 0); + } + }; + + switch (value) { + case CHALLENGE_TYPE_DNS01: + { + formInst.setFieldValue("provider", void 0); + formInst.setFieldValue("providerAccessId", void 0); + formInst.setFieldValue("providerConfig", void 0); + } + break; + + case CHALLENGE_TYPE_HTTP01: + { + formInst.setFieldValue("provider", void 0); + formInst.setFieldValue("providerAccessId", void 0); + formInst.setFieldValue("providerConfig", void 0); + + resetFieldIfInvalid("dnsPropagationWait"); + resetFieldIfInvalid("dnsPropagationTimeout"); + resetFieldIfInvalid("dnsTTL"); + } + break; + } }; - const handleProviderAccessSelect = (value: string) => { - // 切换授权信息时联动 DNS 提供商 - const access = accesses.find((access) => access.id === value); - const provider = Array.from(acmeDns01ProvidersMap.values()).find((provider) => provider.provider === access?.provider); - if (fieldProvider !== provider?.type) { - formInst.setFieldValue("provider", provider?.type); + const handleProviderSelect = (value?: string | undefined) => { + // 切换质询提供商时重置表单,避免其他提供商的配置字段影响当前提供商 + if (initialValues?.provider === value) { + formInst.setFieldValue("providerAccessId", void 0); + formInst.resetFields(["providerConfig"]); + } else { + formInst.setFieldValue("providerAccessId", void 0); + formInst.setFieldValue("providerConfig", void 0); } }; const handleCAProviderSelect = (value?: string | undefined) => { // 切换 CA 提供商时联动授权信息 - if (value === "") { + if (value == null || value === "") { setTimeout(() => { formInst.setFieldValue("caProvider", void 0); formInst.setFieldValue("caProviderAccessId", void 0); @@ -154,7 +287,21 @@ const BizApplyNodeConfigForm = ({ node, ...props }: BizApplyNodeConfigFormProps)
- + + } + rules={[formRule]} + > +
- } + hidden={!showProviderAccess} + label={ + fieldChallengeType === CHALLENGE_TYPE_DNS01 + ? t("workflow_node.apply.form.provider_access_dns01.label") + : fieldChallengeType === CHALLENGE_TYPE_HTTP01 + ? t("workflow_node.apply.form.provider_access_http01.label") + : t("workflow_node.apply.form.provider_access.label") + } >
} - usage="dns" + usage={fieldChallengeType === CHALLENGE_TYPE_DNS01 ? "dns" : fieldChallengeType === CHALLENGE_TYPE_HTTP01 ? "hosting" : "dns-hosting"} afterSubmit={(record) => { - const provider = accessProvidersMap.get(record.provider); - if (provider?.usages?.includes(ACCESS_USAGES.DNS)) { - formInst.setFieldValue("providerAccessId", record.id); - handleProviderAccessSelect(record.id); - } + if (!accessOptionFilter(record.provider, record)) return; + if (fieldChallengeType === CHALLENGE_TYPE_DNS01 && acmeDns01ProvidersMap.get(fieldProvider!)?.provider !== record.provider) return; + if (fieldChallengeType === CHALLENGE_TYPE_HTTP01 && acmeHttp01ProvidersMap.get(fieldProvider!)?.provider !== record.provider) return; + formInst.setFieldValue("providerAccessId", record.id); }} />
{ - if (option.reserve) return false; - - const provider = accessProvidersMap.get(option.provider); - return !!provider?.usages?.includes(ACCESS_USAGES.DNS); - }} + onFilter={accessOptionFilter} />
@@ -295,24 +474,18 @@ const BizApplyNodeConfigForm = ({ node, ...props }: BizApplyNodeConfigFormProps) } usage="ca" afterSubmit={(record) => { - const provider = accessProvidersMap.get(record.provider); - if (provider?.usages?.includes(ACCESS_USAGES.CA)) { - formInst.setFieldValue("caProviderAccessId", record.id); - } + if (accessOptionFilterForCA(record.provider, record)) return; + if (caProvidersMap.get(fieldProvider!)?.provider !== record.provider) return; + formInst.setFieldValue("caProviderAccessId", record.id); }} /> { - if (option.reserve !== "ca") return false; - if (fieldCAProvider) return caProvidersMap.get(fieldCAProvider)?.provider === option.provider; - - const provider = accessProvidersMap.get(option.provider); - return !!provider?.usages?.includes(ACCESS_USAGES.CA); - }} + onFilter={accessOptionFilterForCA} /> @@ -367,6 +540,7 @@ const BizApplyNodeConfigForm = ({ node, ...props }: BizApplyNodeConfigFormProps)