From 9ad0e6fb57635a3b8a2f883447f6f88437c94558 Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Tue, 3 Jun 2025 23:39:27 +0800 Subject: [PATCH] feat: support ssh challenge-response --- internal/deployer/providers.go | 2 + internal/domain/access.go | 6 +- .../pkg/core/deployer/providers/ssh/ssh.go | 77 +++++- migrations/1748959200_upgrade.go | 62 +++++ .../components/access/AccessFormSSHConfig.tsx | 240 ++++++++++-------- ui/src/domain/access.ts | 3 +- ui/src/i18n/locales/en/nls.access.json | 13 +- ui/src/i18n/locales/zh/nls.access.json | 13 +- 8 files changed, 278 insertions(+), 138 deletions(-) create mode 100644 migrations/1748959200_upgrade.go diff --git a/internal/deployer/providers.go b/internal/deployer/providers.go index 6f02c97a..06239710 100644 --- a/internal/deployer/providers.go +++ b/internal/deployer/providers.go @@ -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, diff --git a/internal/domain/access.go b/internal/domain/access.go index 274f2fd0..c6071aef 100644 --- a/internal/domain/access.go +++ b/internal/domain/access.go @@ -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"` diff --git a/internal/pkg/core/deployer/providers/ssh/ssh.go b/internal/pkg/core/deployer/providers/ssh/ssh.go index a52c355e..c68d935b 100644 --- a/internal/pkg/core/deployer/providers/ssh/ssh.go +++ b/internal/pkg/core/deployer/providers/ssh/ssh.go @@ -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 { diff --git a/migrations/1748959200_upgrade.go b/migrations/1748959200_upgrade.go new file mode 100644 index 00000000..daa6b715 --- /dev/null +++ b/migrations/1748959200_upgrade.go @@ -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 + }) +} diff --git a/ui/src/components/access/AccessFormSSHConfig.tsx b/ui/src/components/access/AccessFormSSHConfig.tsx index 84b67e32..56532771 100644 --- a/ui/src/components/access/AccessFormSSHConfig.tsx +++ b/ui/src/components/access/AccessFormSSHConfig.tsx @@ -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) => { onValuesChange?.(values); }; @@ -125,36 +132,39 @@ const AccessFormSSHConfig = ({ form: formInst, formName, disabled, initialValues + + + + - } - > - - + + + + + - } - > - - + + + + - } - > - - + + + + @@ -174,6 +184,60 @@ const AccessFormSSHConfig = ({ form: formInst, formName, disabled, initialValues ); }; + const Fields = () => { + const authMethod = Form.useWatch(["jumpServers", field.name, "authMethod"], formInst); + return ( + <> +
+
+ + + +
+
+ + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + ); + }; + return { key: field.key, label: