diff --git a/README.md b/README.md index 70b9c43..2fc3830 100644 --- a/README.md +++ b/README.md @@ -11,17 +11,17 @@ Website: [https://trzsz.github.io/ssh](https://trzsz.github.io/ssh) ( English ) ## tssh 简介 -你喜欢的 ssh 终端是否有好用的服务器管理功能?是否支持记住密码?是否有好用的文件传输工具? +- 你喜欢的 ssh 终端是否有好用的服务器管理功能?是否支持记住密码?是否有好用的文件传输工具? -tssh 支持选择或搜索 `~/.ssh/config` 中配置的服务器,支持 vim 操作习惯,解决 ssh 终端的服务器管理问题。 +- tssh 支持选择或搜索 `~/.ssh/config` 中配置的服务器,支持 vim 操作习惯,解决 ssh 终端的服务器管理问题。 -tssh 支持一次选择多台服务器,批量登录,并支持批量执行预先指定的命令,方便快速完成批量服务器操作。 +- tssh 支持一次选择多台服务器,批量登录,并支持批量执行预先指定的命令,方便快速完成批量服务器操作。 -tssh 支持配置服务器登录密码,解决每次手工输入密码的麻烦( 在自己能控制的服务器,推荐使用公私钥登录 )。 +- tssh 支持配置服务器登录密码,解决每次手工输入密码的麻烦( 在自己能控制的服务器,推荐使用公私钥登录 )。 -tssh 内置支持 [trzsz](https://trzsz.github.io/cn/) ( trz / tsz ) 文件传输工具,一并解决了 Windows 中使用 `trzsz ssh` 上传速度很慢的问题。 +- tssh 内置支持 [trzsz](https://trzsz.github.io/cn/) ( trz / tsz ) 文件传输工具,一并解决了 Windows 中使用 `trzsz ssh` 上传速度很慢的问题。 -_在作者的 MacOS 上,使用 `trzsz ssh` 的上传速度在 10 MB/s 左右,而使用 `tssh` 可以到 80 MB/s 以上。_ +- _在作者的 MacOS 上,使用 `trzsz ssh` 的上传速度在 10 MB/s 左右,而使用 `tssh` 可以到 80 MB/s 以上。_ ## 安装方法 @@ -164,11 +164,11 @@ _`~/` 代表 HOME 目录。在 Windows 中,请将下文的 `~/` 替换成 `C:\ - 登录服务器,将公钥( 即前面生成密钥对时 `.pub` 后缀的文件内容 )追加写入服务器上的 `~/.ssh/authorized_keys` 文件中。 - 一行代表一个客户端的公钥,注意 `~/.ssh/authorized_keys` 要设置正确的权限: + - 一行代表一个客户端的公钥,注意 `~/.ssh/authorized_keys` 要设置正确的权限: - ```sh - chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys - ``` + ```sh + chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys + ``` - 在客户端配置好 `~/.ssh/config` 文件,举例: @@ -246,7 +246,7 @@ _`~/` 代表 HOME 目录。在 Windows 中,请将下文的 `~/` 替换成 `C:\ #!! ExpectCaseSendPass3 token d7... # 在 ExpectPattern3 匹配之前,若遇到 token 则解码并发送 d7... ``` - 使用 `tssh --debug` 登录,可以看到 `expect` 捕获到的输出,以及其匹配结果和自动输入的交互。 + - 使用 `tssh --debug` 登录,可以看到 `expect` 捕获到的输出,以及其匹配结果和自动输入的交互。 ## 记住密码 @@ -386,11 +386,11 @@ _`~/` 代表 HOME 目录。在 Windows 中,请将下文的 `~/` 替换成 `C:\ - 需要在客户端( 本地电脑 )上安装 `lrzsz`,Windows 可以从 [lrzsz-win32](https://github.com/trzsz/lrzsz-win32/releases) 下载解压并加到 `PATH` 中,也可以如下安装: - ``` - scoop install https://trzsz.github.io/lrzsz.json + ``` + scoop install https://trzsz.github.io/lrzsz.json - choco install lrzsz --version=0.12.21 - ``` + choco install lrzsz --version=0.12.21 + ``` - 关于 `rz / sz` 进度条,己传大小和传输速度会有一点偏差,它的主要作用只是指示传输正在进行中。 @@ -404,7 +404,7 @@ _`~/` 代表 HOME 目录。在 Windows 中,请将下文的 `~/` 替换成 `C:\ - 上文说的“记住密码”和“记住答案”,只要在配置项前面加上 `enc` 则可以配置密文,防止被人窥屏。并且,密文可以解决密码含有`#`的问题。 - 运行 `tssh --enc-secret`,输入密码或答案,可得到用于配置的密文( 相同密码每次运行结果不同 ): + - 运行 `tssh --enc-secret`,输入密码或答案,可得到用于配置的密文( 相同密码每次运行结果不同 ): ``` Host server2 @@ -417,15 +417,11 @@ _`~/` 代表 HOME 目录。在 Windows 中,请将下文的 `~/` 替换成 `C:\ - 运行 `tssh --install-trzsz` 可以将 [trzsz](https://github.com/trzsz/trzsz-go) ( `trz` / `tsz` ) 安装到服务器上。 - 默认安装到 `~/.local/bin/` 目录,可以通过 `--install-path /path/to/install` 指定安装目录。 - - 若 `--install-path` 安装目录含有 `~/`,则必须加上单引号,如`--install-path '~/path'`。 - - 若获取 `trzsz` 的最新版本号失败,可以通过 `--trzsz-version x.x.x` 参数自行指定。 - - 若下载 `trzsz` 的安装包失败,可以自行下载并通过 `--trzsz-bin-path /path/to/trzsz.tar.gz` 参数指定。 - - 注意:`--install-trzsz` 不支持 Windows 服务器,不支持跳板机( 除非以 `ProxyJump` 跳过 )。 + - 默认安装到 `~/.local/bin/` 目录,可以通过 `--install-path /path/to/install` 指定安装目录。 + - 若 `--install-path` 安装目录含有 `~/`,则必须加上单引号,如`--install-path '~/path'`。 + - 若获取 `trzsz` 的最新版本号失败,可以通过 `--trzsz-version x.x.x` 参数自行指定。 + - 若下载 `trzsz` 的安装包失败,可以自行下载并通过 `--trzsz-bin-path /path/to/trzsz.tar.gz` 参数指定。 + - 注意:`--install-trzsz` 不支持 Windows 服务器,不支持跳板机( 除非以 `ProxyJump` 跳过 )。 - 关于修改终端标题,其实无需 `tssh` 就能实现,只要在服务器的 shell 配置文件中(如`~/.bashrc`)配置: @@ -437,7 +433,8 @@ _`~/` 代表 HOME 目录。在 Windows 中,请将下文的 `~/` 替换成 `C:\ PROMPT_COMMAND='echo -ne "\033]0;${USER}@${HOSTNAME}: ${PWD}\007"' ``` - - 如果在 `~/.tssh.conf` 中设置了 `SetTerminalTitle = Yes`,则会在登录后自动设置终端标题,但是服务器上的 `PROMPT_COMMAND` 会覆盖 `tssh` 设置的标题。在 `tssh` 退出后不会重置为原来的标题,你需要在本地 shell 中设置 `PROMPT_COMMAND`,让它覆盖 `tssh` 设置的标题。 + - 如果在 `~/.tssh.conf` 中设置了 `SetTerminalTitle = Yes`,则会在登录后自动设置终端标题,但是服务器上的 `PROMPT_COMMAND` 会覆盖 `tssh` 设置的标题。 + - 在 `tssh` 退出后不会重置为原来的标题,你需要在本地 shell 中设置 `PROMPT_COMMAND`,让它覆盖 `tssh` 设置的标题。 ## 快捷键 diff --git a/tssh/ctrl_unix.go b/tssh/ctrl_unix.go index 5a2c9f3..1e004f4 100644 --- a/tssh/ctrl_unix.go +++ b/tssh/ctrl_unix.go @@ -301,7 +301,12 @@ func connectViaControl(args *sshArgs, param *loginParam) *ssh.Client { return nil } - socket := resolveHomeDir(expandTokens(ctrlPath, args, param, "%CdhikLlnpru")) + socket, err := expandTokens(ctrlPath, args, param, "%CdhikLlnpru") + if err != nil { + warning("expand control socket [%s] failed: %v", socket, err) + return nil + } + socket = resolveHomeDir(socket) switch strings.ToLower(ctrlMaster) { case "yes", "ask": diff --git a/tssh/login.go b/tssh/login.go index 84e631f..11c9d3a 100644 --- a/tssh/login.go +++ b/tssh/login.go @@ -649,7 +649,11 @@ func (p *cmdPipe) Close() error { } func execProxyCommand(args *sshArgs, param *loginParam) (net.Conn, string, error) { - command := resolveHomeDir(expandTokens(param.command, args, param, "%hnpr")) + command, err := expandTokens(param.command, args, param, "%hnpr") + if err != nil { + return nil, param.command, err + } + command = resolveHomeDir(command) debug("exec proxy command: %s", command) argv, err := splitCommandLine(command) diff --git a/tssh/tokens.go b/tssh/tokens.go index 682876a..22427a9 100644 --- a/tssh/tokens.go +++ b/tssh/tokens.go @@ -29,9 +29,44 @@ import ( "crypto/sha1" "fmt" "os" + "regexp" "strings" + "unicode" ) +func isHostValid(host string) bool { + if strings.HasPrefix(host, "-") { + return false + } + for _, ch := range host { + if strings.ContainsRune("'`\"$\\;&<>|(){}", ch) { + return false + } + if unicode.IsSpace(ch) || unicode.IsControl(ch) { + return false + } + } + return true +} + +func isUserValid(user string) bool { + if strings.HasPrefix(user, "-") { + return false + } + if strings.ContainsAny(user, "'`\";&<>|(){}") { + return false + } + // disallow '-' after whitespace + if regexp.MustCompile(`\s-`).MatchString(user) { + return false + } + // disallow \ in last position + if strings.HasSuffix(user, "\\") { + return false + } + return true +} + var getHostname = func() string { hostname, err := os.Hostname() if err != nil { @@ -41,7 +76,7 @@ var getHostname = func() string { return hostname } -func expandTokens(str string, args *sshArgs, param *loginParam, tokens string) string { +func expandTokens(str string, args *sshArgs, param *loginParam, tokens string) (string, error) { var buf strings.Builder state := byte(0) for _, c := range str { @@ -56,19 +91,22 @@ func expandTokens(str string, args *sshArgs, param *loginParam, tokens string) s } state = 0 if !strings.ContainsRune(tokens, c) { - warning("token [%%%c] in [%s] is not supported", c, str) - buf.WriteRune('%') - buf.WriteRune(c) - continue + return "", fmt.Errorf("token [%%%c] in [%s] is not supported", c, str) } switch c { case '%': buf.WriteRune('%') case 'h': + if !isHostValid(param.host) { + return "", fmt.Errorf("hostname contains invalid characters") + } buf.WriteString(param.host) case 'p': buf.WriteString(param.port) case 'r': + if !isUserValid(param.user) { + return "", fmt.Errorf("remote username contains invalid characters") + } buf.WriteString(param.user) case 'n': buf.WriteString(args.Destination) @@ -84,14 +122,11 @@ func expandTokens(str string, args *sshArgs, param *loginParam, tokens string) s hashStr := fmt.Sprintf("%s%s%s%s", getHostname(), param.host, param.port, param.user) buf.WriteString(fmt.Sprintf("%x", sha1.Sum([]byte(hashStr)))) default: - warning("token [%%%c] in [%s] is not supported yet", c, str) - buf.WriteRune('%') - buf.WriteRune(c) + return "", fmt.Errorf("token [%%%c] in [%s] is not supported yet", c, str) } } if state != 0 { - warning("[%s] ends with %% is invalid", str) - buf.WriteRune('%') + return "", fmt.Errorf("[%s] ends with %% is invalid", str) } - return buf.String() + return buf.String(), nil } diff --git a/tssh/tokens_test.go b/tssh/tokens_test.go index 8774188..2bd0b0b 100644 --- a/tssh/tokens_test.go +++ b/tssh/tokens_test.go @@ -26,22 +26,15 @@ SOFTWARE. package tssh import ( - "fmt" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestExpandTokens(t *testing.T) { assert := assert.New(t) - originalWarning := warning - defer func() { - warning = originalWarning - }() - var output string - warning = func(format string, a ...any) { - output = fmt.Sprintf(format, a...) - } + require := require.New(t) originalGetHostname := getHostname defer func() { getHostname = originalGetHostname @@ -56,11 +49,16 @@ func TestExpandTokens(t *testing.T) { port: "1337", user: "penny", } - assertProxyCommand := func(original, expanded, result string) { + assertProxyCommand := func(original, expanded, errMsg string) { t.Helper() - output = "" - assert.Equal(expanded, expandTokens(original, args, param, "%hnpr")) - assert.Equal(result, output) + result, err := expandTokens(original, args, param, "%hnpr") + if errMsg != "" { + require.NotNil(err) + assert.Equal(errMsg, err.Error()) + return + } + require.Nil(err) + assert.Equal(expanded, result) } assertProxyCommand("%%", "%", "") @@ -73,11 +71,16 @@ func TestExpandTokens(t *testing.T) { assertProxyCommand("%l", "%l", "token [%l] in [%l] is not supported") assertProxyCommand("a_%h_%C", "a_127.0.0.1_%C", "token [%C] in [a_%h_%C] is not supported") - assertControlPath := func(original, expanded, result string) { + assertControlPath := func(original, expanded, errMsg string) { t.Helper() - output = "" - assert.Equal(expanded, expandTokens(original, args, param, "%CdhikLlnpru")) - assert.Equal(result, output) + result, err := expandTokens(original, args, param, "%CdhikLlnpru") + if errMsg != "" { + require.NotNil(err) + assert.Equal(errMsg, err.Error()) + return + } + require.Nil(err) + assert.Equal(expanded, result) } assertControlPath("%p和%r", "1337和penny", "") @@ -91,3 +94,73 @@ func TestExpandTokens(t *testing.T) { assertControlPath("p_%h_%d", "p_127.0.0.1_%d", "token [%d] in [p_%h_%d] is not supported yet") assertControlPath("h%", "h%", "[h%] ends with % is invalid") } + +func TestInvalidHost(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + assertInvalidHost := func(host string) { + t.Helper() + _, err := expandTokens("%h", &sshArgs{}, &loginParam{host: host}, "%hnpr") + require.NotNil(err) + assert.Equal("hostname contains invalid characters", err.Error()) + } + + assertInvalidHost("-invalidhostname") + assertInvalidHost("invalid'hostname") + assertInvalidHost("invalid`hostname") + assertInvalidHost("invalid\"hostname") + assertInvalidHost("invalid$hostname") + assertInvalidHost("invalid\\hostname") + assertInvalidHost("invalid;hostname") + assertInvalidHost("invalid&hostname") + assertInvalidHost("invalidhostname") + assertInvalidHost("invalid|hostname") + assertInvalidHost("invalid(hostname") + assertInvalidHost("invalid)hostname") + assertInvalidHost("invalid{hostname") + assertInvalidHost("invalid}hostname") + assertInvalidHost("invalid hostname") + assertInvalidHost("invalid\thostname") + assertInvalidHost("invalid\rhostname") + assertInvalidHost("invalid\nhostname") + assertInvalidHost("invalid\vhostname") + assertInvalidHost("invalid\fhostname") + assertInvalidHost("invalid\u0007hostname") + assertInvalidHost("invalid\u0018hostname") + assertInvalidHost("invalid\u007fhostname") + assertInvalidHost("invalid\u2028hostname") + assertInvalidHost("invalid\u2029hostname") +} + +func TestInvalidUser(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + assertInvalidUser := func(user string) { + t.Helper() + _, err := expandTokens("%r", &sshArgs{}, &loginParam{user: user}, "%hnpr") + require.NotNil(err) + assert.Equal("remote username contains invalid characters", err.Error()) + } + + assertInvalidUser("-invalidusername") + assertInvalidUser("invalid'username") + assertInvalidUser("invalid`username") + assertInvalidUser("invalid\"username") + assertInvalidUser("invalid;username") + assertInvalidUser("invalid&username") + assertInvalidUser("invalidusername") + assertInvalidUser("invalid|username") + assertInvalidUser("invalid(username") + assertInvalidUser("invalid)username") + assertInvalidUser("invalid{username") + assertInvalidUser("invalid}username") + assertInvalidUser("invalid -username") + assertInvalidUser("invalid\t-username") + assertInvalidUser("invalid\r-username") + assertInvalidUser("invalid\n-username") + assertInvalidUser("invalidusername\\") +}