Skip to content

Commit

Permalink
support remember password for control master
Browse files Browse the repository at this point in the history
  • Loading branch information
lonnywong committed Dec 17, 2023
1 parent c0cc872 commit da3fcd3
Show file tree
Hide file tree
Showing 3 changed files with 157 additions and 86 deletions.
49 changes: 36 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ _`~/` 代表 HOME 目录。在 Windows 中,请将下文的 `~/` 替换成 `C:\
Host auto
#!! ExpectCount 3 # 配置自动交互的次数,默认是 0 即无自动交互
#!! ExpectTimeout 30 # 配置自动交互的超时时间(单位:秒),默认是 30 秒
#!! ExpectPattern1 *password # 配置第一个自动交互的匹配表达式
#!! ExpectPattern1 *assword # 配置第一个自动交互的匹配表达式
# 配置第一个自动输入(密文),填 tssh --enc-secret 编码后的字符串,会自动发送 \r 回车
#!! ExpectSendPass1 d7983b4a8ac204bd073ed04741913befd4fbf813ad405d7404cb7d779536f8b87e71106d7780b2
#!! ExpectPattern2 hostname*$ # 配置第二个自动交互的匹配表达式
Expand All @@ -252,7 +252,7 @@ _`~/` 代表 HOME 目录。在 Windows 中,请将下文的 `~/` 替换成 `C:\

- 为了兼容标准 ssh ,密码可以单独配置在 `~/.ssh/password` 中,也可以在 `~/.ssh/config` 中加上 `#!!` 前缀。

- 推荐使用前面密钥认证的方式,密码的安全性弱一些。如果必须要用密码,建议设置好 `~/.ssh/password` 的权限,如:
- 推荐使用前面公钥认证的方式,密码的安全性弱一些。如果必须要用密码,建议设置好 `~/.ssh/password` 的权限,如:

```sh
chmod 700 ~/.ssh && chmod 600 ~/.ssh/password
Expand All @@ -263,17 +263,28 @@ _`~/` 代表 HOME 目录。在 Windows 中,请将下文的 `~/` 替换成 `C:\
```
# 如果配置在 ~/.ssh/config 中,可以加上 `#!!` 前缀,以兼容标准 ssh
Host test1
#!! Password 123456
# 下面是运行 tssh --enc-secret 输入密码 123456 得到的密文串,每次运行结果不同。
#!! encPassword 756b17766f45bdc44c37f811db9990b0880318d5f00f6531b15e068ef1fde2666550
# 如果配置在 ~/.ssh/password 中,则不需要考虑是否兼容标准 ssh
Host test2
Password 123456
# 下面是运行 tssh --enc-secret 输入密码 123456 得到的密文串,每次运行结果不同。
encPassword 051a2f0fdc7d0d40794b845967df4c2d05b5eb0f25339021dc4e02a9d7620070654b
# ~/.ssh/config 和 ~/.ssh/password 是支持通配符的,tssh 会使用第一个匹配到的值。
# 这里希望 test2 使用区别于其他 test* 的密码,所以将 test* 放在了 test2 的后面。
Host test*
Password 111111
Password 111111 # 支持明文密码,但是推荐使用 tssh --enc-secret 简单加密一下。
```

- 如果启用了 `ControlMaster` 多路复用,或者是在 `Warp` 终端,需要使用前面 `自动交互` 的方式实现记住密码的效果。配置方式请参考前面 `自动交互`,加上 `Ctrl` 前缀即可,如:

```
Host ctrl
#!! CtrlExpectCount 1 # 配置自动交互的次数,一般只要输入一次密码
#!! CtrlExpectPattern1 *assword # 配置密码提示语的匹配表达式
#!! CtrlExpectSendPass1 d7983b... # 配置 tssh --enc-secret 编码后的密码
```

- 支持记住私钥的`Passphrase`( 推荐使用 `ssh-agent` )。支持与 `IdentityFile` 一起配置, 支持使用私钥文件名代替 Host 别名设置通用密钥的 `Passphrase`。举例:
Expand All @@ -282,16 +293,18 @@ _`~/` 代表 HOME 目录。在 Windows 中,请将下文的 `~/` 替换成 `C:\
# IdentityFile 和 Passphrase 一起配置,可以加上 `#!!` 前缀,以兼容标准 ssh
Host test1
IdentityFile /path/to/id_rsa
#!! Passphrase 123456
# 下面是运行 tssh --enc-secret 输入密码 123456 得到的密文串,每次运行结果不同。
#!! encPassphrase 6f419911555b0cdc84549ae791ef69f654118d734bb4351de7e83163726ef46d176a
# 在 ~/.ssh/config 中配置通用私钥 ~/.ssh/id_ed25519 对应的 Passphrase
# 可以加上通配符 * 以避免 tssh 搜索和选择时,文件名出现在服务器列表中。
Host id_ed25519*
#!! Passphrase 111111
# 下面是运行 tssh --enc-secret 输入密码 111111 得到的密文串,每次运行结果不同。
#!! encPassphrase 3a929328f2ab1be0ba3fccf29e8125f8e2dac6dab73c946605cf0bb8060b05f02a68
# 在 ~/.ssh/password 中配置则不需要通配符*,也不会出现在服务器列表中。
Host id_rsa
Passphrase 111111
Passphrase 111111 # 支持明文密码,但是推荐使用 tssh --enc-secret 简单加密一下。
```

## 记住答案
Expand All @@ -305,16 +318,22 @@ _`~/` 代表 HOME 目录。在 Windows 中,请将下文的 `~/` 替换成 `C:\
```
# 如果配置在 ~/.ssh/config 中,可以加上 `#!!` 前缀,以兼容标准 ssh
Host test1
QuestionAnswer1 答案一
# 下面是运行 tssh --enc-secret 输入答案 `答案一` 得到的密文串,每次运行结果不同。
encQuestionAnswer1 482de7690ccc5229299ccadd8de1cb7c6d842665f0dc92ff947a302f644817baecbab38601
Host test2
QuestionAnswer1 答案一
QuestionAnswer2 答案二
# 下面是运行 tssh --enc-secret 输入答案 `答案一` 得到的密文串,每次运行结果不同。
encQuestionAnswer1 43e86f1140cf6d8c786248aad95a26f30633f1eab671676b0860ecb5b1a64fb3ec5212dddf
QuestionAnswer2 答案二 # 支持明文答案,但是推荐使用 tssh --enc-secret 简单加密一下。
QuestionAnswer3 答案三
Host test3
6e616d653a20 my_name # 其中 `6e616d653a20` 是问题 `name: ` 的 hex 编码
636f64653a20 my_code # 其中 `636f64653a20` 是问题 `code: ` 的 hex 编码
# 其中 `6e616d653a20` 是问题 `name: ` 的 hex 编码,`enc` 前缀代码配置的是密文串。
# 下面是运行 tssh --enc-secret 输入答案 `my_name` 得到的密文串,每次运行结果不同。
enc6e616d653a20 775f2523ab747384e1661aba7779011cb754b73f2e947672c7fd109607b801d70902d1
636f64653a20 my_code # 其中 `636f64653a20` 是问题 `code: ` 的 hex 编码, `my_code` 是明文答案
```

- 如果启用了 `ControlMaster` 多路复用,或者是在 `Warp` 终端,请参考前面 `自动交互``Ctrl` 前缀来实现。

## 可选配置

- 支持在 `~/.tssh.conf`( Windows 是 `C:\Users\your_name\.tssh.conf` )中进行以下自定义配置:
Expand Down Expand Up @@ -422,6 +441,10 @@ _`~/` 代表 HOME 目录。在 Windows 中,请将下文的 `~/` 替换成 `C:\
sudo ln -sv $(which tssh) /usr/local/bin/ssh
```

- 软链后,`ssh -V` 应输出 `trzsz ssh` 加版本号,如果不是,说明软链不成功,或者在 `PATH``openssh` 的优先级更高,你要软链到另一个地方或者调整 `PATH` 的优先级。

- 软链后,要直接使用 `ssh`,它等价于 `tssh`。如果还是用 `tssh` 是不会支持分块 Blocks 功能的。

- `--dragfile` 参数可能会让 Warp 分块功能失效,请参考前文配置 `EnableDragFile` 来启用拖拽功能。

- 拖拽文件或目录进入 Warp 终端后,可能不会立即触发上传,需要多按一次`回车`键,才会上传。
Expand Down
164 changes: 103 additions & 61 deletions tssh/ctrl_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ package tssh

import (
"bytes"
"context"
"fmt"
"io"
"net"
Expand All @@ -42,21 +43,22 @@ import (
"syscall"
"time"

"github.com/creack/pty"
"golang.org/x/crypto/ssh"
)

type controlMaster struct {
path string
args []string
cmd *exec.Cmd
stdin io.WriteCloser
ptmx *os.File
stdout io.ReadCloser
stderr io.ReadCloser
loggingIn atomic.Bool
exited atomic.Bool
}

func (c *controlMaster) readStderr() {
func (c *controlMaster) handleStderr() {
go func() {
defer c.stderr.Close()
buf := make([]byte, 100)
Expand All @@ -72,81 +74,114 @@ func (c *controlMaster) readStderr() {
}()
}

func (c *controlMaster) readStdout() <-chan error {
done := make(chan error, 1)
func (c *controlMaster) handleStdout() <-chan error {
doneCh := make(chan error, 1)
go func() {
defer close(done)
defer close(doneCh)
buf := make([]byte, 1000)
n, err := c.stdout.Read(buf)
if err != nil {
done <- fmt.Errorf("stdout read failed: %v", err)
doneCh <- fmt.Errorf("read stdout failed: %v", err)
return
}
if !bytes.Equal(bytes.TrimSpace(buf[:n]), []byte("ok")) {
done <- fmt.Errorf("stdout invalid: %v", buf[:n])
doneCh <- fmt.Errorf("control master stdout invalid: %v", buf[:n])
return
}
done <- nil
doneCh <- nil
}()
return done
return doneCh
}

func (c *controlMaster) fillPassword(args *sshArgs, expectCount uint32) (cancel context.CancelFunc) {
var ctx context.Context
if expectTimeout := getExpectTimeout(args, "Ctrl"); expectTimeout > 0 {
ctx, cancel = context.WithTimeout(context.Background(), time.Duration(expectTimeout)*time.Second)
} else {
ctx, cancel = context.WithCancel(context.Background())
}

expect := &sshExpect{
ctx: ctx,
pre: "Ctrl",
out: make(chan []byte, 1),
}
go expect.wrapOutput(c.ptmx, nil, expect.out)
go expect.execInteractions(args.Destination, c.ptmx, expectCount)
return
}

func (c *controlMaster) checkExit() <-chan struct{} {
exit := make(chan struct{}, 1)
exitCh := make(chan struct{}, 1)
go func() {
defer close(exit)
defer close(exitCh)
_ = c.cmd.Wait()
c.exited.Store(true)
exit <- struct{}{}
if c.ptmx != nil {
c.ptmx.Close()
}
exitCh <- struct{}{}
}()
return exit
return exitCh
}

func (c *controlMaster) start() error {
func (c *controlMaster) start(args *sshArgs) error {
var err error
c.cmd = exec.Command(c.path, c.args...)
c.stdin, err = c.cmd.StdinPipe()
if err != nil {
return fmt.Errorf("stdin pipe failed: %v", err)
expectCount := getExpectCount(args, "Ctrl")
if expectCount > 0 {
c.cmd.SysProcAttr = &syscall.SysProcAttr{
Setsid: true,
Setctty: true,
}
pty, tty, err := pty.Open()
if err != nil {
return fmt.Errorf("open pty failed: %v", err)
}
defer tty.Close()
c.cmd.Stdin = tty
c.ptmx = pty
cancel := c.fillPassword(args, expectCount)
defer cancel()
}
c.stdout, err = c.cmd.StdoutPipe()
if err != nil {
if c.stdout, err = c.cmd.StdoutPipe(); err != nil {
return fmt.Errorf("stdout pipe failed: %v", err)
}
c.stderr, err = c.cmd.StderrPipe()
if err != nil {
if c.stderr, err = c.cmd.StderrPipe(); err != nil {
return fmt.Errorf("stderr pipe failed: %v", err)
}
if err := c.cmd.Start(); err != nil {
return fmt.Errorf("start failed: %v", err)
return fmt.Errorf("control master start failed: %v", err)
}

c.loggingIn.Store(true)
defer func() {
c.loggingIn.Store(false)
}()

interrupt := make(chan os.Signal, 1)
signal.Notify(interrupt, os.Interrupt)
defer func() { signal.Stop(interrupt); close(interrupt) }()
intCh := make(chan os.Signal, 1)
signal.Notify(intCh, os.Interrupt)
defer func() { signal.Stop(intCh); close(intCh) }()

c.readStderr()
exit := c.checkExit()
done := c.readStdout()
c.handleStderr()
exitCh := c.checkExit()
doneCh := c.handleStdout()

onExitFuncs = append(onExitFuncs, func() {
c.quit(exit)
})
defer func() {
c.loggingIn.Store(false)
if !c.exited.Load() {
onExitFuncs = append(onExitFuncs, func() {
c.quit(exitCh)
})
}
}()

for {
select {
case err := <-done:
case err := <-doneCh:
return err
case <-exit:
return fmt.Errorf("process exited")
case <-interrupt:
c.quit(exit)
return fmt.Errorf("interrupt")
case <-exitCh:
return fmt.Errorf("control master process exited")
case <-intCh:
c.quit(exitCh)
return fmt.Errorf("user interrupt control master")
}
}
}
Expand All @@ -155,9 +190,8 @@ func (c *controlMaster) quit(exit <-chan struct{}) {
if c.exited.Load() {
return
}
_, _ = c.stdin.Write([]byte("\x03")) // ctrl + c
_ = c.cmd.Process.Signal(syscall.SIGTERM)
timer := time.AfterFunc(200*time.Millisecond, func() {
_ = c.cmd.Process.Signal(syscall.SIGINT)
timer := time.AfterFunc(500*time.Millisecond, func() {
_ = c.cmd.Process.Kill()
})
<-exit
Expand All @@ -184,11 +218,10 @@ func getOpenSSH() (string, error) {
return sshPath, nil
}

func startControlMaster(args *sshArgs) {
func startControlMaster(args *sshArgs) error {
sshPath, err := getOpenSSH()
if err != nil {
warning("can't find ssh to start control master: %v", err)
return
return fmt.Errorf("can't find openssh program: %v", err)
}

cmdArgs := []string{"-T", "-oRemoteCommand=none", "-oConnectTimeout=5"}
Expand Down Expand Up @@ -243,47 +276,56 @@ func startControlMaster(args *sshArgs) {
} else {
cmdArgs = append(cmdArgs, args.Destination)
}
// sleep 2147483 for PowerShell
cmdArgs = append(cmdArgs, "echo ok; sleep 2147483; sleep infinity")
// 10 seconds is enough for tssh to connect
cmdArgs = append(cmdArgs, "echo ok; sleep 10")

if enableDebugLogging {
debug("control master: %s %s", sshPath, strings.Join(cmdArgs, " "))
}

ctrlMaster := &controlMaster{path: sshPath, args: cmdArgs}
if err := ctrlMaster.start(); err != nil {
warning("start control master failed: %v", err)
return
if err := ctrlMaster.start(args); err != nil {
return err
}
debug("start control master success")
return nil
}

func connectViaControl(args *sshArgs, param *loginParam) *ssh.Client {
ctrlMaster := getOptionConfig(args, "ControlMaster")
ctrlPath := getOptionConfig(args, "ControlPath")

switch strings.ToLower(ctrlMaster) {
case "auto", "yes", "ask", "autoask":
startControlMaster(args)
}

switch strings.ToLower(ctrlPath) {
case "", "none":
return nil
}

unixAddr := resolveHomeDir(expandTokens(ctrlPath, args, param, "%CdhikLlnpru"))
debug("login to [%s], socket: %s", args.Destination, unixAddr)
socket := resolveHomeDir(expandTokens(ctrlPath, args, param, "%CdhikLlnpru"))

switch strings.ToLower(ctrlMaster) {
case "yes", "ask":
if isFileExist(socket) {
warning("control socket [%s] already exists, disabling multiplexing", socket)
return nil
}
fallthrough
case "auto", "autoask":
if err := startControlMaster(args); err != nil {
warning("start control master failed: %v", err)
}
}

debug("login to [%s], socket: %s", args.Destination, socket)

conn, err := net.DialTimeout("unix", unixAddr, time.Second)
conn, err := net.DialTimeout("unix", socket, time.Second)
if err != nil {
warning("dial ctrl unix [%s] failed: %v", unixAddr, err)
warning("dial control socket [%s] failed: %v", socket, err)
return nil
}

ncc, chans, reqs, err := NewControlClientConn(conn)
if err != nil {
warning("new ctrl conn [%s] failed: %v", unixAddr, err)
warning("new conn from control socket [%s] failed: %v", socket, err)
return nil
}

Expand Down
Loading

0 comments on commit da3fcd3

Please sign in to comment.