diff --git a/internal/deployer/providers.go b/internal/deployer/providers.go index d4c61a31..fecacc7f 100644 --- a/internal/deployer/providers.go +++ b/internal/deployer/providers.go @@ -871,6 +871,18 @@ func createDeployerProvider(options *deployerProviderOptions) (deployer.Deployer return nil, fmt.Errorf("failed to populate provider access config: %w", err) } + jumpServerConfig := make([]pSSH.JumpServerConfig, len(access.JumpServerConfig)) + for i, jumpServer := range access.JumpServerConfig { + jumpServerConfig[i] = pSSH.JumpServerConfig{ + SshHost: jumpServer.Host, + SshPort: jumpServer.Port, + SshUsername: jumpServer.Username, + SshPassword: jumpServer.Password, + SshKey: jumpServer.Key, + SshKeyPassphrase: jumpServer.KeyPassphrase, + } + } + deployer, err := pSSH.NewDeployer(&pSSH.DeployerConfig{ SshHost: access.Host, SshPort: access.Port, @@ -878,6 +890,7 @@ func createDeployerProvider(options *deployerProviderOptions) (deployer.Deployer SshPassword: access.Password, SshKey: access.Key, SshKeyPassphrase: access.KeyPassphrase, + JumpServerConfig: jumpServerConfig, UseSCP: maputil.GetBool(options.ProviderExtendedConfig, "useSCP"), PreCommand: maputil.GetString(options.ProviderExtendedConfig, "preCommand"), PostCommand: maputil.GetString(options.ProviderExtendedConfig, "postCommand"), diff --git a/internal/domain/access.go b/internal/domain/access.go index 8bc5d0ef..7b792431 100644 --- a/internal/domain/access.go +++ b/internal/domain/access.go @@ -254,12 +254,20 @@ type AccessConfigForSafeLine struct { } type AccessConfigForSSH struct { - Host string `json:"host"` - Port int32 `json:"port"` - Username string `json:"username"` - Password string `json:"password,omitempty"` - Key string `json:"key,omitempty"` - KeyPassphrase string `json:"keyPassphrase,omitempty"` + Host string `json:"host"` + Port int32 `json:"port"` + Username string `json:"username"` + Password string `json:"password,omitempty"` + Key string `json:"key,omitempty"` + KeyPassphrase string `json:"keyPassphrase,omitempty"` + JumpServerConfig []struct { + Host string `json:"host"` + Port int32 `json:"port"` + Username string `json:"username"` + Password string `json:"password,omitempty"` + Key string `json:"key,omitempty"` + KeyPassphrase string `json:"keyPassphrase,omitempty"` + } `json:"jumpServerConfig,omitempty"` } type AccessConfigForSSLCom struct { diff --git a/internal/pkg/core/deployer/providers/ssh/ssh.go b/internal/pkg/core/deployer/providers/ssh/ssh.go index cf09214b..2a67b441 100644 --- a/internal/pkg/core/deployer/providers/ssh/ssh.go +++ b/internal/pkg/core/deployer/providers/ssh/ssh.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "log/slog" + "net" "os" "path/filepath" @@ -16,6 +17,23 @@ import ( certutil "github.com/usual2970/certimate/internal/pkg/utils/cert" ) +type JumpServerConfig struct { + // SSH 主机。 + // 零值时默认为 "localhost"。 + SshHost string `json:"sshHost,omitempty"` + // SSH 端口。 + // 零值时默认为 22。 + SshPort int32 `json:"sshPort,omitempty"` + // SSH 登录用户名。 + SshUsername string `json:"sshUsername,omitempty"` + // SSH 登录密码。 + SshPassword string `json:"sshPassword,omitempty"` + // SSH 登录私钥。 + SshKey string `json:"sshKey,omitempty"` + // SSH 登录私钥口令。 + SshKeyPassphrase string `json:"sshKeyPassphrase,omitempty"` +} + type DeployerConfig struct { // SSH 主机。 // 零值时默认为 "localhost"。 @@ -31,6 +49,8 @@ type DeployerConfig struct { SshKey string `json:"sshKey,omitempty"` // SSH 登录私钥口令。 SshKeyPassphrase string `json:"sshKeyPassphrase,omitempty"` + // 跳板机配置 + JumpServerConfig []JumpServerConfig `json:"jumpServerConfig,omitempty"` // 是否回退使用 SCP。 UseSCP bool `json:"useSCP,omitempty"` // 前置命令。 @@ -97,8 +117,60 @@ func (d *DeployerProvider) Deploy(ctx context.Context, certPEM string, privkeyPE return nil, fmt.Errorf("failed to extract certs: %w", err) } - // 连接 + var targetConn net.Conn + + // 连接到跳板机 + if len(d.config.JumpServerConfig) > 0 { + var jumpClient *ssh.Client + for i, jumpServerConf := range d.config.JumpServerConfig { + d.logger.Info(fmt.Sprintf("connecting to jump server [%d]", i+1), slog.String("host", jumpServerConf.SshHost)) + + var jumpConn net.Conn + // 第一个连接是主机发起,后续通过跳板机发起 + if jumpClient == nil { + jumpConn, err = net.Dial("tcp", fmt.Sprintf("%s:%d", jumpServerConf.SshHost, jumpServerConf.SshPort)) + } else { + jumpConn, err = jumpClient.DialContext(ctx, "tcp", fmt.Sprintf("%s:%d", jumpServerConf.SshHost, jumpServerConf.SshPort)) + } + if err != nil { + return nil, fmt.Errorf("failed to connect to jump server [%d]: %w", i+1, err) + } + defer jumpConn.Close() + + newClient, err := createSshClient( + jumpConn, + jumpServerConf.SshHost, + jumpServerConf.SshPort, + jumpServerConf.SshUsername, + jumpServerConf.SshPassword, + jumpServerConf.SshKey, + jumpServerConf.SshKeyPassphrase, + ) + if err != nil { + return nil, fmt.Errorf("failed to create jump server ssh client[%d]: %w", i+1, err) + } + defer newClient.Close() + + jumpClient = newClient + d.logger.Info(fmt.Sprintf("jump server connected [%d]", i+1), slog.String("host", jumpServerConf.SshHost)) + } + // 通过跳板机发起到目标服务器的TCP连接 + targetConn, err = jumpClient.DialContext(ctx, "tcp", fmt.Sprintf("%s:%d", d.config.SshHost, d.config.SshPort)) + if err != nil { + return nil, fmt.Errorf("failed to connect to target server: %w", err) + } + } else { + // 直接TCP连接到目标服务器 + targetConn, err = net.Dial("tcp", fmt.Sprintf("%s:%d", d.config.SshHost, d.config.SshPort)) + if err != nil { + return nil, fmt.Errorf("failed to connect to target server: %w", err) + } + } + defer targetConn.Close() + + // 通过已有的连接创建目标服务器SSH客户端 client, err := createSshClient( + targetConn, d.config.SshHost, d.config.SshPort, d.config.SshUsername, @@ -189,7 +261,7 @@ func (d *DeployerProvider) Deploy(ctx context.Context, certPEM string, privkeyPE return &deployer.DeployResult{}, nil } -func createSshClient(host string, port int32, username string, password string, key string, keyPassphrase string) (*ssh.Client, error) { +func createSshClient(conn net.Conn, host string, port int32, username string, password string, key string, keyPassphrase string) (*ssh.Client, error) { if host == "" { host = "localhost" } @@ -217,11 +289,16 @@ func createSshClient(host string, port int32, username string, password string, authMethod = ssh.Password(password) } - return ssh.Dial("tcp", fmt.Sprintf("%s:%d", host, port), &ssh.ClientConfig{ + sshConn, chans, reqs, err := ssh.NewClientConn(conn, fmt.Sprintf("%s:%d", host, port), &ssh.ClientConfig{ User: username, Auth: []ssh.AuthMethod{authMethod}, HostKeyCallback: ssh.InsecureIgnoreHostKey(), }) + if err != nil { + return nil, err + } + + return ssh.NewClient(sshConn, chans, reqs), nil } func execSshCommand(sshCli *ssh.Client, command string) (string, string, error) { diff --git a/ui/src/components/access/AccessFormSSHConfig.tsx b/ui/src/components/access/AccessFormSSHConfig.tsx index db1790a4..936988ee 100644 --- a/ui/src/components/access/AccessFormSSHConfig.tsx +++ b/ui/src/components/access/AccessFormSSHConfig.tsx @@ -1,11 +1,12 @@ import { useTranslation } from "react-i18next"; -import { Form, type FormInstance, Input, InputNumber } from "antd"; +import { Button, Collapse, Form, type FormInstance, Input, InputNumber, Space } from "antd"; import { createSchemaFieldRule } from "antd-zod"; import { z } from "zod"; import TextFileInput from "@/components/TextFileInput"; import { type AccessConfigForSSH } from "@/domain/access"; import { validDomainName, validIPv4Address, validIPv6Address, validPortNumber } from "@/utils/validators"; +import { ArrowDownOutlined, ArrowUpOutlined, CloseOutlined, PlusOutlined } from "@ant-design/icons"; type AccessFormSSHConfigFieldValues = Nullish; @@ -114,8 +115,108 @@ const AccessFormSSHConfig = ({ form: formInst, formName, disabled, initialValues > + + } + > + + {(fields, { add, remove, move }) => ( + + {fields?.length > 0 ? ( + { + const Label = () => { + const itemHost = Form.useWatch(["jumpServerConfig", field.name, "host"], formInst); + return ( + + [{t("access.form.ssh_jump_server_config.item.label")} {field.name + 1}] {itemHost ?? ""} + + ); + }; + + return { + key: field.key, + label: + )} + + ); }; export default AccessFormSSHConfig; + diff --git a/ui/src/i18n/locales/en/nls.access.json b/ui/src/i18n/locales/en/nls.access.json index 32ae2885..6bd99c08 100644 --- a/ui/src/i18n/locales/en/nls.access.json +++ b/ui/src/i18n/locales/en/nls.access.json @@ -334,6 +334,10 @@ "access.form.ssh_key_passphrase.label": "SSH key passphrase (Optional)", "access.form.ssh_key_passphrase.placeholder": "Please enter SSH key passphrase", "access.form.ssh_key_passphrase.tooltip": "Optional when using key to connect to SSH.", + "access.form.ssh_jump_server_config.label": "SSH jump server (Optional)", + "access.form.ssh_jump_server_config.tooltip": "Optional when using a jump server to connect to the server.", + "access.form.ssh_jump_server_config.item.label": "Jump Server", + "access.form.ssh_jump_server_config.add": "Add Jump Server", "access.form.sslcom_eab_kid.label": "ACME EAB KID", "access.form.sslcom_eab_kid.placeholder": "Please enter ACME EAB KID", "access.form.sslcom_eab_kid.tooltip": "For more information, see https://www.ssl.com/how-to/generate-acme-credentials-for-reseller-customers/", diff --git a/ui/src/i18n/locales/zh/nls.access.json b/ui/src/i18n/locales/zh/nls.access.json index 5af00a92..ed2dd191 100644 --- a/ui/src/i18n/locales/zh/nls.access.json +++ b/ui/src/i18n/locales/zh/nls.access.json @@ -328,6 +328,10 @@ "access.form.ssh_key_passphrase.label": "SSH 密钥口令(可选)", "access.form.ssh_key_passphrase.placeholder": "请输入 SSH 密钥口令", "access.form.ssh_key_passphrase.tooltip": "使用 SSH 密钥连接到 SSH 时选填。", + "access.form.ssh_jump_server_config.label": "SSH 跳板机(可选)", + "access.form.ssh_jump_server_config.tooltip": "使用跳板机连接到服务器时选填。", + "access.form.ssh_jump_server_config.item.label": "跳板机", + "access.form.ssh_jump_server_config.add": "添加跳板机", "access.form.sslcom_eab_kid.label": "ACME EAB KID", "access.form.sslcom_eab_kid.placeholder": "请输入 ACME EAB KID", "access.form.sslcom_eab_kid.tooltip": "这是什么?请参阅 https://www.ssl.com/how-to/generate-acme-credentials-for-reseller-customers/",