mirror of
https://github.com/usual2970/certimate.git
synced 2025-06-08 21:49:52 +00:00
feat: ssh jump server support
This commit is contained in:
parent
2906576de0
commit
4a8eaa9ffa
@ -871,6 +871,18 @@ func createDeployerProvider(options *deployerProviderOptions) (deployer.Deployer
|
|||||||
return nil, fmt.Errorf("failed to populate provider access config: %w", err)
|
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{
|
deployer, err := pSSH.NewDeployer(&pSSH.DeployerConfig{
|
||||||
SshHost: access.Host,
|
SshHost: access.Host,
|
||||||
SshPort: access.Port,
|
SshPort: access.Port,
|
||||||
@ -878,6 +890,7 @@ func createDeployerProvider(options *deployerProviderOptions) (deployer.Deployer
|
|||||||
SshPassword: access.Password,
|
SshPassword: access.Password,
|
||||||
SshKey: access.Key,
|
SshKey: access.Key,
|
||||||
SshKeyPassphrase: access.KeyPassphrase,
|
SshKeyPassphrase: access.KeyPassphrase,
|
||||||
|
JumpServerConfig: jumpServerConfig,
|
||||||
UseSCP: maputil.GetBool(options.ProviderExtendedConfig, "useSCP"),
|
UseSCP: maputil.GetBool(options.ProviderExtendedConfig, "useSCP"),
|
||||||
PreCommand: maputil.GetString(options.ProviderExtendedConfig, "preCommand"),
|
PreCommand: maputil.GetString(options.ProviderExtendedConfig, "preCommand"),
|
||||||
PostCommand: maputil.GetString(options.ProviderExtendedConfig, "postCommand"),
|
PostCommand: maputil.GetString(options.ProviderExtendedConfig, "postCommand"),
|
||||||
|
@ -260,6 +260,14 @@ type AccessConfigForSSH struct {
|
|||||||
Password string `json:"password,omitempty"`
|
Password string `json:"password,omitempty"`
|
||||||
Key string `json:"key,omitempty"`
|
Key string `json:"key,omitempty"`
|
||||||
KeyPassphrase string `json:"keyPassphrase,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 {
|
type AccessConfigForSSLCom struct {
|
||||||
|
@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
@ -16,6 +17,23 @@ import (
|
|||||||
certutil "github.com/usual2970/certimate/internal/pkg/utils/cert"
|
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 {
|
type DeployerConfig struct {
|
||||||
// SSH 主机。
|
// SSH 主机。
|
||||||
// 零值时默认为 "localhost"。
|
// 零值时默认为 "localhost"。
|
||||||
@ -31,6 +49,8 @@ type DeployerConfig struct {
|
|||||||
SshKey string `json:"sshKey,omitempty"`
|
SshKey string `json:"sshKey,omitempty"`
|
||||||
// SSH 登录私钥口令。
|
// SSH 登录私钥口令。
|
||||||
SshKeyPassphrase string `json:"sshKeyPassphrase,omitempty"`
|
SshKeyPassphrase string `json:"sshKeyPassphrase,omitempty"`
|
||||||
|
// 跳板机配置
|
||||||
|
JumpServerConfig []JumpServerConfig `json:"jumpServerConfig,omitempty"`
|
||||||
// 是否回退使用 SCP。
|
// 是否回退使用 SCP。
|
||||||
UseSCP bool `json:"useSCP,omitempty"`
|
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)
|
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(
|
client, err := createSshClient(
|
||||||
|
targetConn,
|
||||||
d.config.SshHost,
|
d.config.SshHost,
|
||||||
d.config.SshPort,
|
d.config.SshPort,
|
||||||
d.config.SshUsername,
|
d.config.SshUsername,
|
||||||
@ -189,7 +261,7 @@ func (d *DeployerProvider) Deploy(ctx context.Context, certPEM string, privkeyPE
|
|||||||
return &deployer.DeployResult{}, nil
|
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 == "" {
|
if host == "" {
|
||||||
host = "localhost"
|
host = "localhost"
|
||||||
}
|
}
|
||||||
@ -217,11 +289,16 @@ func createSshClient(host string, port int32, username string, password string,
|
|||||||
authMethod = ssh.Password(password)
|
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,
|
User: username,
|
||||||
Auth: []ssh.AuthMethod{authMethod},
|
Auth: []ssh.AuthMethod{authMethod},
|
||||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
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) {
|
func execSshCommand(sshCli *ssh.Client, command string) (string, string, error) {
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
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 { createSchemaFieldRule } from "antd-zod";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import TextFileInput from "@/components/TextFileInput";
|
import TextFileInput from "@/components/TextFileInput";
|
||||||
import { type AccessConfigForSSH } from "@/domain/access";
|
import { type AccessConfigForSSH } from "@/domain/access";
|
||||||
import { validDomainName, validIPv4Address, validIPv6Address, validPortNumber } from "@/utils/validators";
|
import { validDomainName, validIPv4Address, validIPv6Address, validPortNumber } from "@/utils/validators";
|
||||||
|
import { ArrowDownOutlined, ArrowUpOutlined, CloseOutlined, PlusOutlined } from "@ant-design/icons";
|
||||||
|
|
||||||
type AccessFormSSHConfigFieldValues = Nullish<AccessConfigForSSH>;
|
type AccessFormSSHConfigFieldValues = Nullish<AccessConfigForSSH>;
|
||||||
|
|
||||||
@ -114,8 +115,108 @@ const AccessFormSSHConfig = ({ form: formInst, formName, disabled, initialValues
|
|||||||
>
|
>
|
||||||
<Input.Password allowClear autoComplete="new-password" placeholder={t("access.form.ssh_key_passphrase.placeholder")} />
|
<Input.Password allowClear autoComplete="new-password" placeholder={t("access.form.ssh_key_passphrase.placeholder")} />
|
||||||
</Form.Item>
|
</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>
|
</Form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AccessFormSSHConfig;
|
export default AccessFormSSHConfig;
|
||||||
|
|
||||||
|
@ -334,6 +334,10 @@
|
|||||||
"access.form.ssh_key_passphrase.label": "SSH key passphrase (Optional)",
|
"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.placeholder": "Please enter SSH key passphrase",
|
||||||
"access.form.ssh_key_passphrase.tooltip": "Optional when using key to connect to SSH.",
|
"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.label": "ACME EAB KID",
|
||||||
"access.form.sslcom_eab_kid.placeholder": "Please enter 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>",
|
"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>",
|
||||||
|
@ -328,6 +328,10 @@
|
|||||||
"access.form.ssh_key_passphrase.label": "SSH 密钥口令(可选)",
|
"access.form.ssh_key_passphrase.label": "SSH 密钥口令(可选)",
|
||||||
"access.form.ssh_key_passphrase.placeholder": "请输入 SSH 密钥口令",
|
"access.form.ssh_key_passphrase.placeholder": "请输入 SSH 密钥口令",
|
||||||
"access.form.ssh_key_passphrase.tooltip": "使用 SSH 密钥连接到 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.label": "ACME EAB KID",
|
||||||
"access.form.sslcom_eab_kid.placeholder": "请输入 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>",
|
"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>",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user