Merge pull request #699 from Lensual/feat-ssh-jumpserver

feat: ssh jump server support (#49) (#95)
This commit is contained in:
RHQYZ 2025-05-20 21:42:53 +08:00 committed by GitHub
commit 7469310fdb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 267 additions and 23 deletions

View File

@ -962,6 +962,18 @@ func createDeployerProvider(options *deployerProviderOptions) (deployer.Deployer
return nil, fmt.Errorf("failed to populate provider access config: %w", err)
}
jumpServers := make([]pSSH.JumpServerConfig, len(access.JumpServerConfig))
for i, jumpServer := range access.JumpServerConfig {
jumpServers[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,
@ -969,18 +981,19 @@ func createDeployerProvider(options *deployerProviderOptions) (deployer.Deployer
SshPassword: access.Password,
SshKey: access.Key,
SshKeyPassphrase: access.KeyPassphrase,
UseSCP: maputil.GetBool(options.ProviderServiceConfig, "useSCP"),
PreCommand: maputil.GetString(options.ProviderServiceConfig, "preCommand"),
PostCommand: maputil.GetString(options.ProviderServiceConfig, "postCommand"),
OutputFormat: pSSH.OutputFormatType(maputil.GetOrDefaultString(options.ProviderServiceConfig, "format", string(pSSH.OUTPUT_FORMAT_PEM))),
OutputCertPath: maputil.GetString(options.ProviderServiceConfig, "certPath"),
OutputServerCertPath: maputil.GetString(options.ProviderServiceConfig, "certPathForServerOnly"),
OutputIntermediaCertPath: maputil.GetString(options.ProviderServiceConfig, "certPathForIntermediaOnly"),
OutputKeyPath: maputil.GetString(options.ProviderServiceConfig, "keyPath"),
PfxPassword: maputil.GetString(options.ProviderServiceConfig, "pfxPassword"),
JksAlias: maputil.GetString(options.ProviderServiceConfig, "jksAlias"),
JksKeypass: maputil.GetString(options.ProviderServiceConfig, "jksKeypass"),
JksStorepass: maputil.GetString(options.ProviderServiceConfig, "jksStorepass"),
JumpServerConfig: jumpServers,
UseSCP: maputil.GetBool(options.ProviderExtendedConfig, "useSCP"),
PreCommand: maputil.GetString(options.ProviderExtendedConfig, "preCommand"),
PostCommand: maputil.GetString(options.ProviderExtendedConfig, "postCommand"),
OutputFormat: pSSH.OutputFormatType(maputil.GetOrDefaultString(options.ProviderExtendedConfig, "format", string(pSSH.OUTPUT_FORMAT_PEM))),
OutputCertPath: maputil.GetString(options.ProviderExtendedConfig, "certPath"),
OutputServerCertPath: maputil.GetString(options.ProviderExtendedConfig, "certPathForServerOnly"),
OutputIntermediaCertPath: maputil.GetString(options.ProviderExtendedConfig, "certPathForIntermediaOnly"),
OutputKeyPath: maputil.GetString(options.ProviderExtendedConfig, "keyPath"),
PfxPassword: maputil.GetString(options.ProviderExtendedConfig, "pfxPassword"),
JksAlias: maputil.GetString(options.ProviderExtendedConfig, "jksAlias"),
JksKeypass: maputil.GetString(options.ProviderExtendedConfig, "jksKeypass"),
JksStorepass: maputil.GetString(options.ProviderExtendedConfig, "jksStorepass"),
})
return deployer, err
}

View File

@ -284,12 +284,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 {

View File

@ -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) {

View File

@ -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<AccessConfigForSSH>;
@ -28,6 +29,42 @@ const initFormModel = (): AccessFormSSHConfigFieldValues => {
const AccessFormSSHConfig = ({ form: formInst, formName, disabled, initialValues, onValuesChange }: AccessFormSSHConfigProps) => {
const { t } = useTranslation();
const jumpServerConfigItemSchema = z
.object({
host: z.string().refine((v) => validDomainName(v) || validIPv4Address(v) || validIPv6Address(v), t("common.errmsg.host_invalid")),
port: z.preprocess(
(v) => Number(v),
z
.number()
.int(t("access.form.ssh_port.placeholder"))
.refine((v) => validPortNumber(v), t("common.errmsg.port_invalid"))
),
username: z
.string()
.min(1, t("access.form.ssh_username.placeholder"))
.max(64, t("common.errmsg.string_max", { max: 64 })),
password: z
.string()
.max(64, t("common.errmsg.string_max", { max: 64 }))
.nullish(),
key: z
.string()
.max(20480, t("common.errmsg.string_max", { max: 20480 }))
.nullish(),
keyPassphrase: z
.string()
.max(20480, t("common.errmsg.string_max", { max: 20480 }))
.nullish(),
})
.superRefine((data, ctx) => {
if (data.keyPassphrase && !data.key) {
ctx.addIssue({
path: ["keyPassphrase"],
code: z.ZodIssueCode.custom,
message: t("access.form.ssh_key.placeholder"),
});
}
});
const formSchema = z.object({
host: z.string().refine((v) => validDomainName(v) || validIPv4Address(v) || validIPv6Address(v), t("common.errmsg.host_invalid")),
port: z.preprocess(
@ -39,7 +76,7 @@ const AccessFormSSHConfig = ({ form: formInst, formName, disabled, initialValues
),
username: z
.string()
.min(1, "access.form.ssh_username.placeholder")
.min(1, t("access.form.ssh_username.placeholder"))
.max(64, t("common.errmsg.string_max", { max: 64 })),
password: z
.string()
@ -54,6 +91,7 @@ const AccessFormSSHConfig = ({ form: formInst, formName, disabled, initialValues
.max(20480, t("common.errmsg.string_max", { max: 20480 }))
.nullish()
.refine((v) => !v || formInst.getFieldValue("key"), t("access.form.ssh_key.placeholder")),
jumpServerConfig: jumpServerConfigItemSchema.array().nullish(),
});
const formRule = createSchemaFieldRule(formSchema);
@ -114,8 +152,108 @@ const AccessFormSSHConfig = ({ form: formInst, formName, disabled, initialValues
>
<Input.Password allowClear autoComplete="new-password" placeholder={t("access.form.ssh_key_passphrase.placeholder")} />
</Form.Item>
<Form.Item
label={t("access.form.ssh_jump_server_config.label")}
tooltip={<span dangerouslySetInnerHTML={{ __html: t("access.form.ssh_jump_server_config.tooltip") }}></span>}
>
<Form.List name="jumpServerConfig">
{(fields, { add, remove, move }) => (
<Space className="w-full" direction="vertical" size="small">
{fields?.length > 0 ? (
<Collapse
items={fields.map((field, index) => {
const Label = () => {
const itemHost = Form.useWatch(["jumpServerConfig", field.name, "host"], formInst);
return (
<span style={{ userSelect: "none" }}>
[{t("access.form.ssh_jump_server_config.item.label")} {field.name + 1}] {itemHost ?? ""}
</span>
);
};
return {
key: field.key,
label: <Label />, // 这里用组件渲染
extra: (
<Space>
<ArrowUpOutlined
onClick={(e) => {
move(index, index - 1);
e.stopPropagation();
}}
/>
<ArrowDownOutlined
onClick={(e) => {
move(index, index + 1);
e.stopPropagation();
}}
/>
<CloseOutlined
onClick={(e) => {
remove(field.name);
e.stopPropagation();
}}
/>
</Space>
),
children: (
<>
<div className="flex space-x-2">
<div className="w-2/3">
<Form.Item name={[field.name, "host"]} label={t("access.form.ssh_host.label")} rules={[formRule]}>
<Input placeholder={t("access.form.ssh_host.placeholder")} />
</Form.Item>
</div>
<div className="w-1/3">
<Form.Item name={[field.name, "port"]} label={t("access.form.ssh_port.label")} rules={[formRule]}>
<InputNumber className="w-full" placeholder={t("access.form.ssh_port.placeholder")} min={1} max={65535} />
</Form.Item>
</div>
</div>
<Form.Item name={[field.name, "username"]} label={t("access.form.ssh_username.label")} rules={[formRule]}>
<Input autoComplete="new-password" placeholder={t("access.form.ssh_username.placeholder")} />
</Form.Item>
<Form.Item
name={[field.name, "password"]}
label={t("access.form.ssh_password.label")}
rules={[formRule]}
tooltip={<span dangerouslySetInnerHTML={{ __html: t("access.form.ssh_password.tooltip") }}></span>}
>
<Input.Password allowClear autoComplete="new-password" placeholder={t("access.form.ssh_password.placeholder")} />
</Form.Item>
<Form.Item
name={[field.name, "key"]}
label={t("access.form.ssh_key.label")}
rules={[formRule]}
tooltip={<span dangerouslySetInnerHTML={{ __html: t("access.form.ssh_key.tooltip") }}></span>}
>
<TextFileInput allowClear autoSize={{ minRows: 1, maxRows: 5 }} placeholder={t("access.form.ssh_key.placeholder")} />
</Form.Item>
<Form.Item
name={[field.name, "keyPassphrase"]}
label={t("access.form.ssh_key_passphrase.label")}
rules={[formRule]}
tooltip={<span dangerouslySetInnerHTML={{ __html: t("access.form.ssh_key_passphrase.tooltip") }}></span>}
>
<Input.Password allowClear autoComplete="new-password" placeholder={t("access.form.ssh_key_passphrase.placeholder")} />
</Form.Item>
</>
),
};
})}
/>
) : null}
<Button type="dashed" className="w-full" icon={<PlusOutlined />} onClick={() => add()}>
{t("access.form.ssh_jump_server_config.add")}
</Button>
</Space>
)}
</Form.List>
</Form.Item>
</Form>
);
};
export default AccessFormSSHConfig;

View File

@ -392,6 +392,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 <a href=\"https://www.ssl.com/how-to/generate-acme-credentials-for-reseller-customers/#ftoc-heading-6\" target=\"_blank\">https://www.ssl.com/how-to/generate-acme-credentials-for-reseller-customers/</a>",

View File

@ -386,6 +386,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": "这是什么?请参阅 <a href=\"https://www.ssl.com/how-to/generate-acme-credentials-for-reseller-customers/#ftoc-heading-6\" target=\"_blank\">https://www.ssl.com/how-to/generate-acme-credentials-for-reseller-customers/</a>",