From f83d4e5f4ba96061fd0e7aa0329a8c051b7ae106 Mon Sep 17 00:00:00 2001 From: Lonny Wong Date: Sat, 30 Dec 2023 17:55:27 +0800 Subject: [PATCH] support one-time password #69 --- README.md | 35 ++++++++++++--- tssh/ctrl_unix.go | 19 +++++--- tssh/expect.go | 109 +++++++++++++++++++++++++++++----------------- tssh/login.go | 41 +++++++++++++++++ 4 files changed, 153 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index ea7372d..d66477f 100644 --- a/README.md +++ b/README.md @@ -229,10 +229,10 @@ _`~/` 代表 HOME 目录。在 Windows 中,请将下文的 `~/` 替换成 `C:\ ``` Host auto - #!! ExpectCount 3 # 配置自动交互的次数,默认是 0 即无自动交互 + #!! ExpectCount 5 # 配置自动交互的次数,默认是 0 即无自动交互 #!! ExpectTimeout 30 # 配置自动交互的超时时间(单位:秒),默认是 30 秒 #!! ExpectPattern1 *assword # 配置第一个自动交互的匹配表达式 - # 配置第一个自动输入(密文),填 tssh --enc-secret 编码后的字符串,会自动发送 \r 回车 + # 配置第一个自动输入(密文),这是由 tssh --enc-secret 编码得到的字符串,tssh 会自动发送 \r 回车 #!! ExpectSendPass1 d7983b4a8ac204bd073ed04741913befd4fbf813ad405d7404cb7d779536f8b87e71106d7780b2 #!! ExpectPattern2 hostname*$ # 配置第二个自动交互的匹配表达式 #!! ExpectSendText2 echo tssh expect\r # 配置第二个自动输入(明文),需要指定 \r 才会发送回车 @@ -243,10 +243,16 @@ _`~/` 代表 HOME 目录。在 Windows 中,请将下文的 `~/` 替换成 `C:\ #!! ExpectSendText3 ssh xxx\r # 配置第三个自动输入,也可以换成 ExpectSendPass3 然后配置密文 #!! ExpectCaseSendText3 yes/no y\r # 在 ExpectPattern3 匹配之前,若遇到 yes/no 则发送 y 并回车 #!! ExpectCaseSendText3 y/n yes\r # 在 ExpectPattern3 匹配之前,若遇到 y/n 则发送 yes 并回车 - #!! ExpectCaseSendPass3 token d7... # 在 ExpectPattern3 匹配之前,若遇到 token 则解码并发送 d7... + #!! ExpectCaseSendPass3 token d7... # 在 ExpectPattern3 匹配之前,若遇到 token 则解码 d7... 并发送 + # -------------------------------------------------- + #!! ExpectPattern4 token: # 配置第四个自动交互的匹配表达式(这里以动态密码举例) + #!! ExpectSendOtp4 oathtool --totp -b xxxxx # 配置获取动态密码的命令(明文) + #!! ExpectPattern5 token: # 配置第五个自动交互的匹配表达式(这里以动态密码举例) + # 下面是运行 tssh --enc-secret 输入命令 oathtool --totp -b xxxxx 得到的密文串 + #!! ExpectSendEncOtp5 77b4ce85d087b39909e563efb165659b22b9ea700a537f1258bdf56ce6fdd6ea70bc7591ea5c01918537a65433133bc0bd5ed3e4 ``` - - 使用 `tssh --debug` 登录,可以看到 `expect` 捕获到的输出,以及其匹配结果和自动输入的交互。 + - 配置 `ExpectCount` 大于 `0` 之后,使用 `tssh --debug` 登录,可以看到 `expect` 捕获到的输出。 ## 记住密码 @@ -311,7 +317,7 @@ _`~/` 代表 HOME 目录。在 Windows 中,请将下文的 `~/` 替换成 `C:\ - 除了私钥和密码,还有一种登录方式,英文叫 keyboard interactive ,是服务器返回一些问题,客户端提供正确的答案就能登录,很多自定义的一次性密码就是利用这种方式实现的。 -- 如果答案是固定不变的,`tssh` 支持“记住答案”。大部分都是只有一个问题,只要配置 `QuestionAnswer1` 即可。对于有多个问题的,每个问题答案可按序号进行配置,也可以按问题的 hex 编码进行配置。 +- 对于只有一个问题,且答案(密码)固定不变的,只要配置 `QuestionAnswer1` 即可。对于有多个问题的,可以按问题的序号进行配置,也可以按问题的 hex 编码进行配置。 - 使用 `tssh --debug` 登录,会输出问题的 hex 编码,从而知道该如何使用 hex 编码进行配置。配置举例: @@ -332,8 +338,27 @@ _`~/` 代表 HOME 目录。在 Windows 中,请将下文的 `~/` 替换成 `C:\ 636f64653a20 my_code # 其中 `636f64653a20` 是问题 `code: ` 的 hex 编码, `my_code` 是明文答案 ``` +- 对于可以通过命令行获取到的动态密码,则可以如下配置(同样支持按序号或 hex 编码进行配置): + + ``` + Host otp + OtpCommand1 oathtool --totp -b xxxxx # 按序号配置获取动态密码的命令 + otp636f64653a20 oathtool --totp -b xxxxx # 按 `code: ` 的 hex 编码 `636f64653a20` 配置获取动态密码的命令 + # 下面是运行 tssh --enc-secret 输入命令 oathtool --totp -b xxxxx 得到的密文串,加上 `enc` 前缀进行配置 + encOtpCommand2 77b4ce85d087b39909e563efb165659b22b9ea700a537f1258bdf56ce6fdd6ea70bc7591ea5c01918537a65433133bc0bd5ed3e4 + encotp636f64653a20 77b4ce85d087b39909e563efb165659b22b9ea700a537f1258bdf56ce6fdd6ea70bc7591ea5c01918537a65433133bc0bd5ed3e4 + ``` + - 如果启用了 `ControlMaster` 多路复用,或者是在 `Warp` 终端,请参考前面 `自动交互` 加 `Ctrl` 前缀来实现。 + ``` + Host ctrl_otp + #!! CtrlExpectCount 1 # 配置自动交互的次数,一般只要输入一次密码 + #!! CtrlExpectPattern1 token: # 配置密码提示语的匹配表达式(这里以动态密码举例) + #!! CtrlExpectSendOtp1 oathtool --totp -b xxxxx # 配置获取动态密码的命令(明文) + #!! CtrlExpectSendEncOtp1 77b4ce85d0... # 或者配置 tssh --enc-secret 得到的密文串 + ``` + ## 可选配置 - 支持在 `~/.tssh.conf`( Windows 是 `C:\Users\your_name\.tssh.conf` )中进行以下自定义配置: diff --git a/tssh/ctrl_unix.go b/tssh/ctrl_unix.go index 1e004f4..08c447d 100644 --- a/tssh/ctrl_unix.go +++ b/tssh/ctrl_unix.go @@ -96,7 +96,8 @@ func (c *controlMaster) handleStdout() <-chan error { func (c *controlMaster) fillPassword(args *sshArgs, expectCount uint32) (cancel context.CancelFunc) { var ctx context.Context - if expectTimeout := getExpectTimeout(args, "Ctrl"); expectTimeout > 0 { + expectTimeout := getExpectTimeout(args, "Ctrl") + if expectTimeout > 0 { ctx, cancel = context.WithTimeout(context.Background(), time.Duration(expectTimeout)*time.Second) } else { ctx, cancel = context.WithCancel(context.Background()) @@ -105,10 +106,15 @@ func (c *controlMaster) fillPassword(args *sshArgs, expectCount uint32) (cancel expect := &sshExpect{ ctx: ctx, pre: "Ctrl", - out: make(chan []byte, 1), + out: make(chan []byte, 100), } go expect.wrapOutput(c.ptmx, nil, expect.out) - go expect.execInteractions(args.Destination, c.ptmx, expectCount) + go func() { + expect.execInteractions(args.Destination, c.ptmx, expectCount) + if ctx.Err() == context.DeadlineExceeded { + warning("expect timeout after %d seconds", expectTimeout) + } + }() return } @@ -187,7 +193,7 @@ func (c *controlMaster) start(args *sshArgs) error { } } -func (c *controlMaster) quit(exit <-chan struct{}) { +func (c *controlMaster) quit(exitCh <-chan struct{}) { if c.exited.Load() { return } @@ -195,7 +201,10 @@ func (c *controlMaster) quit(exit <-chan struct{}) { timer := time.AfterFunc(500*time.Millisecond, func() { _ = c.cmd.Process.Kill() }) - <-exit + select { + case <-time.After(time.Second): + case <-exitCh: + } timer.Stop() } diff --git a/tssh/expect.go b/tssh/expect.go index f854657..ae66f11 100644 --- a/tssh/expect.go +++ b/tssh/expect.go @@ -187,6 +187,7 @@ func (e *sshExpect) captureOutput(reader io.Reader, ch chan<- []byte) ([]byte, e case <-e.ctx.Done(): return buf, nil case ch <- buf: + debug("expect capture output: %s", strconv.QuoteToASCII(string(buf))) } } if err == io.EOF { @@ -239,26 +240,26 @@ func (e *sshExpect) waitForPattern(pattern string, caseSends *caseSendList) erro var buf []byte select { case <-e.ctx.Done(): - warning("expect timeout") return e.ctx.Err() case buf = <-e.out: case buf = <-e.err: } + if len(buf) == 0 { + continue + } output := strconv.QuoteToASCII(string(buf)) - debug("expect output: %s", output) caseSends.handleOutput(output[1 : len(output)-1]) builder.WriteString(output[1 : len(output)-1]) - if re.MatchString(builder.String()) { + if pattern != "" && re.MatchString(builder.String()) { debug("expect match: %s", pattern) // cleanup for next expect for { select { - case buf = <-e.out: - case buf = <-e.err: + case <-e.out: + case <-e.err: default: return nil } - debug("expect output: %s", strconv.QuoteToASCII(string(buf))) } } else { debug("expect not match: %s", pattern) @@ -266,49 +267,74 @@ func (e *sshExpect) waitForPattern(pattern string, caseSends *caseSendList) erro } } +func (e *sshExpect) getExpectSendInput(alias string, idx uint32) (string, string) { + if pass := getExConfig(alias, fmt.Sprintf("%sExpectSendPass%d", e.pre, idx)); pass != "" { + secret, err := decodeSecret(pass) + if err != nil { + warning("decode %sExpectSendPass%d [%s] failed: %v", e.pre, idx, pass, err) + return "", "" + } + return secret, "" + } + + if text := getExConfig(alias, fmt.Sprintf("%sExpectSendText%d", e.pre, idx)); text != "" { + return decodeExpectText(text), text + } + + if encOtp := getExConfig(alias, fmt.Sprintf("%sExpectSendEncOtp%d", e.pre, idx)); encOtp != "" { + command, err := decodeSecret(encOtp) + if err != nil { + warning("decode %sExpectSendEncOtp%d [%s] failed: %v", e.pre, idx, encOtp, err) + return "", "" + } + return getOtpCommandOutput(command), "" + } + + if command := getExConfig(alias, fmt.Sprintf("%sExpectSendOtp%d", e.pre, idx)); command != "" { + return getOtpCommandOutput(command), "" + } + + return "", "" +} + func (e *sshExpect) execInteractions(alias string, writer io.Writer, expectCount uint32) { - for i := uint32(1); i <= expectCount; i++ { - pattern := getExConfig(alias, fmt.Sprintf("%sExpectPattern%d", e.pre, i)) - debug("expect pattern %d: %s", i, pattern) + for idx := uint32(1); idx <= expectCount; idx++ { + pattern := getExConfig(alias, fmt.Sprintf("%sExpectPattern%d", e.pre, idx)) if pattern != "" { - caseSends := &caseSendList{writer: writer} - for _, cfg := range getAllExConfig(alias, fmt.Sprintf("%sExpectCaseSendPass%d", e.pre, i)) { - if err := caseSends.addCaseSendPass(cfg); err != nil { - warning("Invalid ExpectCaseSendPass%d: %v", i, err) - } - } - for _, cfg := range getAllExConfig(alias, fmt.Sprintf("%sExpectCaseSendText%d", e.pre, i)) { - if err := caseSends.addCaseSendText(cfg); err != nil { - warning("Invalid ExpectCaseSendText%d: %v", i, err) - } + debug("expect %d pattern: %s", idx, pattern) + } else { + warning("expect %d pattern is empty, no output will be matched", idx) + } + caseSends := &caseSendList{writer: writer} + for _, cfg := range getAllExConfig(alias, fmt.Sprintf("%sExpectCaseSendPass%d", e.pre, idx)) { + if err := caseSends.addCaseSendPass(cfg); err != nil { + warning("Invalid ExpectCaseSendPass%d: %v", idx, err) } - if err := e.waitForPattern(pattern, caseSends); err != nil { - return + } + for _, cfg := range getAllExConfig(alias, fmt.Sprintf("%sExpectCaseSendText%d", e.pre, idx)) { + if err := caseSends.addCaseSendText(cfg); err != nil { + warning("Invalid ExpectCaseSendText%d: %v", idx, err) } } + if err := e.waitForPattern(pattern, caseSends); err != nil { + return + } if e.ctx.Err() != nil { return } - var input string - secret := getExConfig(alias, fmt.Sprintf("%sExpectSendPass%d", e.pre, i)) - if secret != "" { - pass, err := decodeSecret(secret) - if err != nil { - warning("decode secret [%s] failed: %v", secret, err) - return - } - debug("expect send %d: %s\\r", i, strings.Repeat("*", len(pass))) - input = pass + "\r" + input, text := e.getExpectSendInput(alias, idx) + if input == "" { + warning("expect %d send nothing", idx) + continue + } + if text != "" { + debug("expect %d send: %s", idx, text) } else { - text := getExConfig(alias, fmt.Sprintf("%sExpectSendText%d", e.pre, i)) - if text == "" { - continue - } - debug("expect send %d: %s", i, text) - input = decodeExpectText(text) + debug("expect %d send: %s\\r", idx, strings.Repeat("*", len(input))) + input += "\r" } if err := writeAll(writer, []byte(input)); err != nil { - warning("expect send input failed: %v", err) + warning("expect %d send input failed: %v", idx, err) return } } @@ -352,7 +378,8 @@ func execExpectInteractions(args *sshArgs, serverIn io.Writer, var ctx context.Context var cancel context.CancelFunc - if expectTimeout := getExpectTimeout(args, ""); expectTimeout > 0 { + expectTimeout := getExpectTimeout(args, "") + if expectTimeout > 0 { ctx, cancel = context.WithTimeout(context.Background(), time.Duration(expectTimeout)*time.Second) } else { ctx, cancel = context.WithCancel(context.Background()) @@ -370,8 +397,8 @@ func execExpectInteractions(args *sshArgs, serverIn io.Writer, expect.execInteractions(args.Destination, serverIn, expectCount) if ctx.Err() == context.DeadlineExceeded { - // enter for shell prompt if timeout - _, _ = serverIn.Write([]byte("\r")) + warning("expect timeout after %d seconds", expectTimeout) + _, _ = serverIn.Write([]byte("\r")) // enter for shell prompt if timeout } return outReader, errReader diff --git a/tssh/login.go b/tssh/login.go index ad3533c..bb8552f 100644 --- a/tssh/login.go +++ b/tssh/login.go @@ -27,6 +27,7 @@ package tssh import ( "bufio" + "bytes" "crypto/x509" "encoding/hex" "fmt" @@ -482,6 +483,32 @@ func getPasswordAuthMethod(args *sshArgs, host, user string) ssh.AuthMethod { }), 3) } +func getOtpCommandOutput(command string) string { + argv, err := splitCommandLine(command) + if err != nil || len(argv) == 0 { + warning("split otp command failed: %v", err) + return "" + } + if enableDebugLogging { + for i, arg := range argv { + debug("otp command argv[%d] = %s", i, arg) + } + } + cmd := exec.Command(argv[0], argv[1:]...) + var outBuf, errBuf bytes.Buffer + cmd.Stdout = &outBuf + cmd.Stderr = &errBuf + if err := cmd.Run(); err != nil { + if errBuf.Len() > 0 { + warning("exec otp command failed: %v, %s", err, strings.TrimSpace(errBuf.String())) + } else { + warning("exec otp command failed: %v", err) + } + return "" + } + return strings.TrimSpace(outBuf.String()) +} + func readQuestionAnswerConfig(dest string, idx int, question string) string { qhex := hex.EncodeToString([]byte(question)) debug("the hex code for question '%s' is %s", question, qhex) @@ -489,12 +516,26 @@ func readQuestionAnswerConfig(dest string, idx int, question string) string { return answer } + if command := getSecretConfig(dest, "otp"+qhex); command != "" { + if answer := getOtpCommandOutput(command); answer != "" { + return answer + } + } + qkey := fmt.Sprintf("QuestionAnswer%d", idx) debug("the configuration key for question '%s' is %s", question, qkey) if answer := getSecretConfig(dest, qkey); answer != "" { return answer } + qcmd := fmt.Sprintf("OtpCommand%d", idx) + debug("the otp command key for question '%s' is %s", question, qcmd) + if command := getSecretConfig(dest, qcmd); command != "" { + if answer := getOtpCommandOutput(command); answer != "" { + return answer + } + } + return "" }