Skip to content

Commit

Permalink
support one-time password #69
Browse files Browse the repository at this point in the history
  • Loading branch information
lonnywong committed Dec 30, 2023
1 parent 9c936c6 commit f83d4e5
Show file tree
Hide file tree
Showing 4 changed files with 153 additions and 51 deletions.
35 changes: 30 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 才会发送回车
Expand All @@ -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` 捕获到的输出。

## 记住密码

Expand Down Expand Up @@ -311,7 +317,7 @@ _`~/` 代表 HOME 目录。在 Windows 中,请将下文的 `~/` 替换成 `C:\

- 除了私钥和密码,还有一种登录方式,英文叫 keyboard interactive ,是服务器返回一些问题,客户端提供正确的答案就能登录,很多自定义的一次性密码就是利用这种方式实现的。

- 如果答案是固定不变的,`tssh` 支持“记住答案”。大部分都是只有一个问题,只要配置 `QuestionAnswer1` 即可。对于有多个问题的,每个问题答案可按序号进行配置,也可以按问题的 hex 编码进行配置。
- 对于只有一个问题,且答案(密码)固定不变的,只要配置 `QuestionAnswer1` 即可。对于有多个问题的,可以按问题的序号进行配置,也可以按问题的 hex 编码进行配置。

- 使用 `tssh --debug` 登录,会输出问题的 hex 编码,从而知道该如何使用 hex 编码进行配置。配置举例:

Expand All @@ -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` )中进行以下自定义配置:
Expand Down
19 changes: 14 additions & 5 deletions tssh/ctrl_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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
}

Expand Down Expand Up @@ -187,15 +193,18 @@ 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
}
_ = c.cmd.Process.Signal(syscall.SIGINT)
timer := time.AfterFunc(500*time.Millisecond, func() {
_ = c.cmd.Process.Kill()
})
<-exit
select {
case <-time.After(time.Second):
case <-exitCh:
}
timer.Stop()
}

Expand Down
109 changes: 68 additions & 41 deletions tssh/expect.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -239,76 +240,101 @@ 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)
}
}
}

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
}
}
Expand Down Expand Up @@ -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())
Expand All @@ -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
Expand Down
41 changes: 41 additions & 0 deletions tssh/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ package tssh

import (
"bufio"
"bytes"
"crypto/x509"
"encoding/hex"
"fmt"
Expand Down Expand Up @@ -482,19 +483,59 @@ 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)
if answer := getSecretConfig(dest, qhex); answer != "" {
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 ""
}

Expand Down

0 comments on commit f83d4e5

Please sign in to comment.