diff --git a/README.md b/README.md index d9a9e7c..6a5fb1e 100644 --- a/README.md +++ b/README.md @@ -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*$ # 配置第二个自动交互的匹配表达式 @@ -252,7 +252,7 @@ _`~/` 代表 HOME 目录。在 Windows 中,请将下文的 `~/` 替换成 `C:\ - 为了兼容标准 ssh ,密码可以单独配置在 `~/.ssh/password` 中,也可以在 `~/.ssh/config` 中加上 `#!!` 前缀。 -- 推荐使用前面密钥认证的方式,密码的安全性弱一些。如果必须要用密码,建议设置好 `~/.ssh/password` 的权限,如: +- 推荐使用前面公钥认证的方式,密码的安全性弱一些。如果必须要用密码,建议设置好 `~/.ssh/password` 的权限,如: ```sh chmod 700 ~/.ssh && chmod 600 ~/.ssh/password @@ -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`。举例: @@ -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 简单加密一下。 ``` ## 记住答案 @@ -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` )中进行以下自定义配置: @@ -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 终端后,可能不会立即触发上传,需要多按一次`回车`键,才会上传。 diff --git a/tssh/ctrl_unix.go b/tssh/ctrl_unix.go index 4d48d9b..fc7b570 100644 --- a/tssh/ctrl_unix.go +++ b/tssh/ctrl_unix.go @@ -29,6 +29,7 @@ package tssh import ( "bytes" + "context" "fmt" "io" "net" @@ -42,6 +43,7 @@ import ( "syscall" "time" + "github.com/creack/pty" "golang.org/x/crypto/ssh" ) @@ -49,14 +51,14 @@ 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) @@ -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") } } } @@ -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 @@ -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"} @@ -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 } diff --git a/tssh/expect.go b/tssh/expect.go index e6c886f..f854657 100644 --- a/tssh/expect.go +++ b/tssh/expect.go @@ -170,6 +170,7 @@ func (c *caseSendList) handleOutput(output string) { } type sshExpect struct { + pre string ctx context.Context out chan []byte err chan []byte @@ -192,7 +193,9 @@ func (e *sshExpect) captureOutput(reader io.Reader, ch chan<- []byte) ([]byte, e return nil, err } if err != nil { - warning("expect read output failed: %v", err) + if e.ctx.Err() == nil { + warning("expect read output failed: %v", err) + } return nil, err } } @@ -204,6 +207,9 @@ func (e *sshExpect) wrapOutput(reader io.Reader, writer io.Writer, ch chan []byt if err != nil { return } + if writer == nil { + return + } for data := range ch { if err := writeAll(writer, data); err != nil { warning("expect write output failed: %v", err) @@ -262,16 +268,16 @@ func (e *sshExpect) waitForPattern(pattern string, caseSends *caseSendList) erro func (e *sshExpect) execInteractions(alias string, writer io.Writer, expectCount uint32) { for i := uint32(1); i <= expectCount; i++ { - pattern := getExConfig(alias, fmt.Sprintf("ExpectPattern%d", i)) + pattern := getExConfig(alias, fmt.Sprintf("%sExpectPattern%d", e.pre, i)) debug("expect pattern %d: %s", i, pattern) if pattern != "" { caseSends := &caseSendList{writer: writer} - for _, cfg := range getAllExConfig(alias, fmt.Sprintf("ExpectCaseSendPass%d", i)) { + 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("ExpectCaseSendText%d", i)) { + 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) } @@ -284,7 +290,7 @@ func (e *sshExpect) execInteractions(alias string, writer io.Writer, expectCount return } var input string - secret := getExConfig(alias, fmt.Sprintf("ExpectSendPass%d", i)) + secret := getExConfig(alias, fmt.Sprintf("%sExpectSendPass%d", e.pre, i)) if secret != "" { pass, err := decodeSecret(secret) if err != nil { @@ -294,7 +300,7 @@ func (e *sshExpect) execInteractions(alias string, writer io.Writer, expectCount debug("expect send %d: %s\\r", i, strings.Repeat("*", len(pass))) input = pass + "\r" } else { - text := getExConfig(alias, fmt.Sprintf("ExpectSendText%d", i)) + text := getExConfig(alias, fmt.Sprintf("%sExpectSendText%d", e.pre, i)) if text == "" { continue } @@ -308,8 +314,8 @@ func (e *sshExpect) execInteractions(alias string, writer io.Writer, expectCount } } -func getExpectCount(args *sshArgs) uint32 { - expectCount := getExOptionConfig(args, "ExpectCount") +func getExpectCount(args *sshArgs, prefix string) uint32 { + expectCount := getExOptionConfig(args, prefix+"ExpectCount") if expectCount == "" { return 0 } @@ -321,8 +327,8 @@ func getExpectCount(args *sshArgs) uint32 { return uint32(count) } -func getExpectTimeout(args *sshArgs) uint32 { - expectCount := getExOptionConfig(args, "ExpectTimeout") +func getExpectTimeout(args *sshArgs, prefix string) uint32 { + expectCount := getExOptionConfig(args, prefix+"ExpectTimeout") if expectCount == "" { return kDefaultExpectTimeout } @@ -336,7 +342,7 @@ func getExpectTimeout(args *sshArgs) uint32 { func execExpectInteractions(args *sshArgs, serverIn io.Writer, serverOut io.Reader, serverErr io.Reader) (io.Reader, io.Reader) { - expectCount := getExpectCount(args) + expectCount := getExpectCount(args, "") if expectCount <= 0 { return serverOut, serverErr } @@ -346,7 +352,7 @@ func execExpectInteractions(args *sshArgs, serverIn io.Writer, var ctx context.Context var cancel context.CancelFunc - if expectTimeout := getExpectTimeout(args); expectTimeout > 0 { + if expectTimeout := getExpectTimeout(args, ""); expectTimeout > 0 { ctx, cancel = context.WithTimeout(context.Background(), time.Duration(expectTimeout)*time.Second) } else { ctx, cancel = context.WithCancel(context.Background())