feat: support ssh challenge-response

This commit is contained in:
Fu Diwei 2025-06-03 23:39:27 +08:00
parent 7d55383cf7
commit 9ad0e6fb57
8 changed files with 278 additions and 138 deletions

View File

@ -997,6 +997,7 @@ func createDeployerProvider(options *deployerProviderOptions) (deployer.Deployer
jumpServers[i] = pSSH.JumpServerConfig{
SshHost: jumpServer.Host,
SshPort: jumpServer.Port,
SshAuthMethod: jumpServer.AuthMethod,
SshUsername: jumpServer.Username,
SshPassword: jumpServer.Password,
SshKey: jumpServer.Key,
@ -1007,6 +1008,7 @@ func createDeployerProvider(options *deployerProviderOptions) (deployer.Deployer
deployer, err := pSSH.NewDeployer(&pSSH.DeployerConfig{
SshHost: access.Host,
SshPort: access.Port,
SshAuthMethod: access.AuthMethod,
SshUsername: access.Username,
SshPassword: access.Password,
SshKey: access.Key,

View File

@ -315,14 +315,16 @@ type AccessConfigForSlackBot struct {
type AccessConfigForSSH struct {
Host string `json:"host"`
Port int32 `json:"port"`
Username string `json:"username"`
AuthMethod string `json:"authMethod,omitempty"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
Key string `json:"key,omitempty"`
KeyPassphrase string `json:"keyPassphrase,omitempty"`
JumpServers []struct {
Host string `json:"host"`
Port int32 `json:"port"`
Username string `json:"username"`
AuthMethod string `json:"authMethod,omitempty"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
Key string `json:"key,omitempty"`
KeyPassphrase string `json:"keyPassphrase,omitempty"`

View File

@ -24,7 +24,12 @@ type JumpServerConfig struct {
// 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"`
@ -41,7 +46,12 @@ type DeployerConfig struct {
// 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"`
@ -141,6 +151,7 @@ func (d *DeployerProvider) Deploy(ctx context.Context, certPEM string, privkeyPE
jumpConn,
jumpServerConf.SshHost,
jumpServerConf.SshPort,
jumpServerConf.SshAuthMethod,
jumpServerConf.SshUsername,
jumpServerConf.SshPassword,
jumpServerConf.SshKey,
@ -174,6 +185,7 @@ func (d *DeployerProvider) Deploy(ctx context.Context, certPEM string, privkeyPE
targetConn,
d.config.SshHost,
d.config.SshPort,
d.config.SshAuthMethod,
d.config.SshUsername,
d.config.SshPassword,
d.config.SshKey,
@ -262,7 +274,7 @@ func (d *DeployerProvider) Deploy(ctx context.Context, certPEM string, privkeyPE
return &deployer.DeployResult{}, nil
}
func createSshClient(conn net.Conn, host string, port int32, username string, password string, key string, keyPassphrase string) (*ssh.Client, error) {
func createSshClient(conn net.Conn, host string, port int32, authMethod string, username, password, key, keyPassphrase string) (*ssh.Client, error) {
if host == "" {
host = "localhost"
}
@ -271,28 +283,65 @@ func createSshClient(conn net.Conn, host string, port int32, username string, pa
port = 22
}
var authMethod ssh.AuthMethod
if key != "" {
var signer ssh.Signer
var err error
if username == "" {
username = "root"
}
if keyPassphrase != "" {
signer, err = ssh.ParsePrivateKeyWithPassphrase([]byte(key), []byte(keyPassphrase))
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 {
signer, err = ssh.ParsePrivateKey([]byte(key))
authMethod = AUTH_METHOD_NONE
}
}
authentications := make([]ssh.AuthMethod, 0)
switch authMethod {
case AUTH_METHOD_NONE:
{
}
if err != nil {
return nil, err
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", questions[0])
}))
}
authMethod = ssh.PublicKeys(signer)
} else {
authMethod = ssh.Password(password)
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)
}
sshConn, chans, reqs, err := ssh.NewClientConn(conn, fmt.Sprintf("%s:%d", host, port), &ssh.ClientConfig{
User: username,
Auth: []ssh.AuthMethod{authMethod},
Auth: authentications,
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
})
if err != nil {

View File

@ -0,0 +1,62 @@
package migrations
import (
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
tracer := NewTracer("(v0.3)1748959200")
tracer.Printf("go ...")
// migrate data
{
collection, err := app.FindCollectionByNameOrId("4yzbv8urny5ja1e")
if err != nil {
return err
}
records, err := app.FindAllRecords(collection)
if err != nil {
return err
}
for _, record := range records {
changed := false
if record.GetString("provider") == "ssh" {
config := make(map[string]any)
if err := record.UnmarshalJSONField("config", &config); err != nil {
return err
}
if config["authMethod"] == nil || config["authMethod"] == "" {
if config["key"] != nil && config["key"] != "" {
config["authMethod"] = "key"
} else if config["password"] != nil && config["password"] != "" {
config["authMethod"] = "password"
} else {
config["authMethod"] = "none"
}
record.Set("config", config)
changed = true
}
}
if changed {
if err := app.Save(record); err != nil {
return err
}
tracer.Printf("record #%s in collection '%s' updated", record.Id, collection.Name)
}
}
}
tracer.Printf("done")
return nil
}, func(app core.App) error {
return nil
})
}

View File

@ -1,9 +1,10 @@
import { useTranslation } from "react-i18next";
import { ArrowDownOutlined, ArrowUpOutlined, CloseOutlined, PlusOutlined } from "@ant-design/icons";
import { Button, Collapse, Form, type FormInstance, Input, InputNumber, Space } from "antd";
import { Button, Collapse, Form, type FormInstance, Input, InputNumber, Select, Space } from "antd";
import { createSchemaFieldRule } from "antd-zod";
import { z } from "zod";
import Show from "@/components/Show";
import TextFileInput from "@/components/TextFileInput";
import { type AccessConfigForSSH } from "@/domain/access";
import { validDomainName, validIPv4Address, validIPv6Address, validPortNumber } from "@/utils/validators";
@ -18,10 +19,15 @@ export type AccessFormSSHConfigProps = {
onValuesChange?: (values: AccessFormSSHConfigFieldValues) => void;
};
const AUTH_METHOD_NONE = "none" as const;
const AUTH_METHOD_PASSWORD = "password" as const;
const AUTH_METHOD_KEY = "key" as const;
const initFormModel = (): AccessFormSSHConfigFieldValues => {
return {
host: "127.0.0.1",
port: 22,
authMethod: AUTH_METHOD_PASSWORD,
username: "root",
};
};
@ -38,6 +44,9 @@ const AccessFormSSHConfig = ({ form: formInst, formName, disabled, initialValues
.int(t("access.form.ssh_port.placeholder"))
.refine((v) => validPortNumber(v), t("common.errmsg.port_invalid"))
),
authMethod: z.union([z.literal(AUTH_METHOD_NONE), z.literal(AUTH_METHOD_PASSWORD), z.literal(AUTH_METHOD_KEY)], {
message: t("access.form.ssh_auth_method.placeholder"),
}),
username: z
.string()
.min(1, t("access.form.ssh_username.placeholder"))
@ -45,11 +54,13 @@ const AccessFormSSHConfig = ({ form: formInst, formName, disabled, initialValues
password: z
.string()
.max(64, t("common.errmsg.string_max", { max: 64 }))
.nullish(),
.nullish()
.refine((v) => fieldAuthMethod !== AUTH_METHOD_PASSWORD || !!v?.trim(), t("access.form.ssh_password.placeholder")),
key: z
.string()
.max(20480, t("common.errmsg.string_max", { max: 20480 }))
.nullish(),
.nullish()
.refine((v) => fieldAuthMethod !== AUTH_METHOD_KEY || !!v?.trim(), t("access.form.ssh_key.placeholder")),
keyPassphrase: z
.string()
.max(20480, t("common.errmsg.string_max", { max: 20480 }))
@ -57,47 +68,43 @@ const AccessFormSSHConfig = ({ form: formInst, formName, disabled, initialValues
.refine((v) => !v || formInst.getFieldValue("key"), t("access.form.ssh_key.placeholder")),
jumpServers: z
.array(
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"),
});
}
})
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"))
),
authMethod: z.union([z.literal(AUTH_METHOD_NONE), z.literal(AUTH_METHOD_PASSWORD), z.literal(AUTH_METHOD_KEY)], {
message: t("access.form.ssh_auth_method.placeholder"),
}),
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(),
}),
{ message: t("access.form.ssh_jump_servers.errmsg.invalid") }
)
.nullish(),
});
const formRule = createSchemaFieldRule(formSchema);
const fieldAuthMethod = Form.useWatch("authMethod", formInst);
const handleFormChange = (_: unknown, values: z.infer<typeof formSchema>) => {
onValuesChange?.(values);
};
@ -125,36 +132,39 @@ const AccessFormSSHConfig = ({ form: formInst, formName, disabled, initialValues
</div>
</div>
<Form.Item name="authMethod" label={t("access.form.ssh_auth_method.label")} rules={[formRule]}>
<Select placeholder={t("access.form.ssh_auth_method.placeholder")}>
<Select.Option key={AUTH_METHOD_NONE} value={AUTH_METHOD_NONE}>
{t("access.form.ssh_auth_method.option.none.label")}
</Select.Option>
<Select.Option key={AUTH_METHOD_PASSWORD} value={AUTH_METHOD_PASSWORD}>
{t("access.form.ssh_auth_method.option.password.label")}
</Select.Option>
<Select.Option key={AUTH_METHOD_KEY} value={AUTH_METHOD_KEY}>
{t("access.form.ssh_auth_method.option.key.label")}
</Select.Option>
</Select>
</Form.Item>
<Form.Item 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="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>
<Show when={fieldAuthMethod === AUTH_METHOD_PASSWORD}>
<Form.Item name="password" label={t("access.form.ssh_password.label")} rules={[formRule]}>
<Input.Password allowClear autoComplete="new-password" placeholder={t("access.form.ssh_password.placeholder")} />
</Form.Item>
</Show>
<Form.Item
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>
<Show when={fieldAuthMethod === AUTH_METHOD_KEY}>
<Form.Item name="key" label={t("access.form.ssh_key.label")} rules={[formRule]}>
<TextFileInput allowClear autoSize={{ minRows: 1, maxRows: 5 }} placeholder={t("access.form.ssh_key.placeholder")} />
</Form.Item>
<Form.Item
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>
<Form.Item name="keyPassphrase" label={t("access.form.ssh_key_passphrase.label")} rules={[formRule]}>
<Input.Password allowClear autoComplete="new-password" placeholder={t("access.form.ssh_key_passphrase.placeholder")} />
</Form.Item>
</Show>
<Form.Item name="jumpServers" label={t("access.form.ssh_jump_servers.label")} rules={[formRule]}>
<Form.List name="jumpServers">
@ -174,6 +184,60 @@ const AccessFormSSHConfig = ({ form: formInst, formName, disabled, initialValues
);
};
const Fields = () => {
const authMethod = Form.useWatch(["jumpServers", field.name, "authMethod"], formInst);
return (
<>
<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, "authMethod"]} label={t("access.form.ssh_auth_method.label")} rules={[formRule]}>
<Select placeholder={t("access.form.ssh_auth_method.placeholder")}>
<Select.Option key={AUTH_METHOD_NONE} value={AUTH_METHOD_NONE}>
{t("access.form.ssh_auth_method.option.none.label")}
</Select.Option>
<Select.Option key={AUTH_METHOD_PASSWORD} value={AUTH_METHOD_PASSWORD}>
{t("access.form.ssh_auth_method.option.password.label")}
</Select.Option>
<Select.Option key={AUTH_METHOD_KEY} value={AUTH_METHOD_KEY}>
{t("access.form.ssh_auth_method.option.key.label")}
</Select.Option>
</Select>
</Form.Item>
<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>
<Show when={authMethod === AUTH_METHOD_PASSWORD}>
<Form.Item name={[field.name, "password"]} label={t("access.form.ssh_password.label")} rules={[formRule]}>
<Input.Password allowClear autoComplete="new-password" placeholder={t("access.form.ssh_password.placeholder")} />
</Form.Item>
</Show>
<Show when={authMethod === AUTH_METHOD_KEY}>
<Form.Item name={[field.name, "key"]} label={t("access.form.ssh_key.label")} rules={[formRule]}>
<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]}>
<Input.Password allowClear autoComplete="new-password" placeholder={t("access.form.ssh_key_passphrase.placeholder")} />
</Form.Item>
</Show>
</>
);
};
return {
key: field.key,
label: <Label />,
@ -214,58 +278,12 @@ const AccessFormSSHConfig = ({ form: formInst, formName, disabled, initialValues
/>
</Space.Compact>
),
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>
</>
),
children: <Fields />,
};
})}
/>
) : null}
<Button className="w-full" type="dashed" icon={<PlusOutlined />} onClick={() => add()}>
<Button className="w-full" type="dashed" icon={<PlusOutlined />} onClick={() => add(initFormModel())}>
{t("access.form.ssh_jump_servers.add")}
</Button>
</Space>

View File

@ -379,7 +379,8 @@ export type AccessConfigForSlackBot = {
export type AccessConfigForSSH = {
host: string;
port: number;
username: string;
authMethod?: string;
username?: string;
password?: string;
key?: string;
keyPassphrase?: string;

View File

@ -378,18 +378,21 @@
"access.form.ssh_host.placeholder": "Please enter server host",
"access.form.ssh_port.label": "Server port",
"access.form.ssh_port.placeholder": "Please enter server port",
"access.form.ssh_auth_method.label": "Authentication method",
"access.form.ssh_auth_method.placeholder": "Please select authentication method",
"access.form.ssh_auth_method.option.none.label": "None",
"access.form.ssh_auth_method.option.password.label": "Password",
"access.form.ssh_auth_method.option.key.label": "SSH key",
"access.form.ssh_username.label": "Username",
"access.form.ssh_username.placeholder": "Please enter username",
"access.form.ssh_password.label": "Password (Optional)",
"access.form.ssh_password.label": "Password",
"access.form.ssh_password.placeholder": "Please enter password",
"access.form.ssh_password.tooltip": "Required when using password to connect to SSH.",
"access.form.ssh_key.label": "SSH key (Optional)",
"access.form.ssh_key.label": "SSH key",
"access.form.ssh_key.placeholder": "Please enter SSH key",
"access.form.ssh_key.tooltip": "Required when using key to connect to SSH.",
"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_servers.label": "SSH jump server (Optional)",
"access.form.ssh_jump_servers.errmsg.invalid": "Please configure a valid jump server",
"access.form.ssh_jump_servers.item.label": "Jump server",
"access.form.ssh_jump_servers.add": "Add jump server",
"access.form.sslcom_eab_kid.label": "ACME EAB KID",

View File

@ -378,18 +378,21 @@
"access.form.ssh_host.placeholder": "请输入服务器地址",
"access.form.ssh_port.label": "服务器端口",
"access.form.ssh_port.placeholder": "请输入服务器端口",
"access.form.ssh_auth_method.label": "认证方式",
"access.form.ssh_auth_method.placeholder": "请选择认证方式",
"access.form.ssh_auth_method.option.none.label": "无",
"access.form.ssh_auth_method.option.password.label": "密码",
"access.form.ssh_auth_method.option.key.label": "密钥",
"access.form.ssh_username.label": "用户名",
"access.form.ssh_username.placeholder": "请输入用户名",
"access.form.ssh_password.label": "密码(可选)",
"access.form.ssh_password.label": "密码",
"access.form.ssh_password.placeholder": "请输入密码",
"access.form.ssh_password.tooltip": "使用密码连接到 SSH 时必填。<br>该字段与密钥文件字段二选一,如果同时填写优先使用 SSH 密钥登录。",
"access.form.ssh_key.label": "SSH 密钥(可选)",
"access.form.ssh_key.label": "SSH 密钥",
"access.form.ssh_key.placeholder": "请输入 SSH 密钥文件内容",
"access.form.ssh_key.tooltip": "使用 SSH 密钥连接到 SSH 时必填。<br>该字段与密码字段二选一,如果同时填写优先使用 SSH 密钥登录。",
"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_servers.label": "SSH 跳板机(可选)",
"access.form.ssh_jump_servers.errmsg.invalid": "请配置有效的 SSH 跳板机",
"access.form.ssh_jump_servers.item.label": "跳板机",
"access.form.ssh_jump_servers.add": "添加跳板机",
"access.form.sslcom_eab_kid.label": "ACME EAB KID",