package agent import ( "bufio" "context" "errors" "fmt" "io" "log" "net" "os" "path/filepath" "strconv" "strings" "sync" "time" "golang.org/x/crypto/ssh" "github.com/x-grk/longpolling-remote-access/internal/proto" ) // An SSH terminal: the agent connects to another host by SSH and bridges the // remote shell to the session, as it does a local one. The panel is told how // the connection goes (connecting, signing in, in) to show it. // sshKnownHosts holds the host keys seen, next to the agent's state: a host // is trusted the first time, and a changed key is refused. func (a *Agent) sshKnownHosts() string { return filepath.Join(filepath.Dir(a.cfg.StatePath), "ssh_known_hosts") } var knownHostsMu sync.Mutex // hostKeyCheck trusts a new host and remembers its key; a host whose key // has changed since is refused, as ssh would. func (a *Agent) hostKeyCheck(seen *string) ssh.HostKeyCallback { return func(hostname string, _ net.Addr, key ssh.PublicKey) error { knownHostsMu.Lock() defer knownHostsMu.Unlock() fp := ssh.FingerprintSHA256(key) *seen = fp path := a.sshKnownHosts() line := hostname + " " + key.Type() + " " if f, err := os.Open(path); err == nil { sc := bufio.NewScanner(f) for sc.Scan() { fields := strings.Fields(sc.Text()) if len(fields) != 3 || fields[0] != hostname { continue } if fields[1] == key.Type() && fields[2] == fp { f.Close() return nil } if fields[1] == key.Type() { f.Close() return fmt.Errorf("ключ хоста %s изменился: был %s, теперь %s. Если так и должно быть, удалите строку с этим хостом из %s на агенте", hostname, fields[2], fp, path) } } f.Close() } _ = os.MkdirAll(filepath.Dir(path), 0o700) f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) if err == nil { _, _ = f.WriteString(line + fp + "\n") f.Close() } return nil } } // sshAuth is how to sign in: the key given, the password given (also // answered to keyboard-interactive prompts), or the agent user's own keys. func sshAuth(t proto.SSHTarget) ([]ssh.AuthMethod, error) { var methods []ssh.AuthMethod if t.Key != "" { var signer ssh.Signer var err error if t.Passphrase != "" { signer, err = ssh.ParsePrivateKeyWithPassphrase([]byte(t.Key), []byte(t.Passphrase)) } else { signer, err = ssh.ParsePrivateKey([]byte(t.Key)) } var missing *ssh.PassphraseMissingError if errors.As(err, &missing) { return nil, errors.New("ключ защищён фразой - введите её") } if err != nil { return nil, fmt.Errorf("ключ не читается: %v", err) } methods = append(methods, ssh.PublicKeys(signer)) } if t.Password != "" { pw := t.Password methods = append(methods, ssh.Password(pw), ssh.KeyboardInteractive(func(_, _ string, qs []string, _ []bool) ([]string, error) { ans := make([]string, len(qs)) for i := range ans { ans[i] = pw } return ans, nil })) } if len(methods) == 0 { var signers []ssh.Signer for _, n := range []string{"id_ed25519", "id_ecdsa", "id_rsa"} { b, err := os.ReadFile(filepath.Join(homeDir(""), ".ssh", n)) if err != nil { continue } if s, err := ssh.ParsePrivateKey(b); err == nil { signers = append(signers, s) } } if len(signers) == 0 { return nil, errors.New("не задан ни пароль, ни ключ, и в ~/.ssh агента нет ключей без фразы") } methods = append(methods, ssh.PublicKeys(signers...)) } return methods, nil } // sshShell is the remote shell as a stream: its output (stdout and stderr, // one on a PTY) to read, keystrokes to write. type sshShell struct { client *ssh.Client sess *ssh.Session stdin io.WriteCloser out *io.PipeReader once sync.Once } func (s *sshShell) Read(b []byte) (int, error) { return s.out.Read(b) } func (s *sshShell) Write(b []byte) (int, error) { return s.stdin.Write(b) } func (s *sshShell) Resize(cols, rows uint16) { if cols > 0 && rows > 0 { _ = s.sess.WindowChange(int(rows), int(cols)) } } func (s *sshShell) Close() error { s.once.Do(func() { _ = s.sess.Close() _ = s.client.Close() _ = s.out.Close() }) return nil } // runSSH connects to the target and bridges its shell to the session. func (a *Agent) runSSH(parent context.Context, sid string, t proto.SSHTarget) { status := func(st, note string) { a.postOut(context.Background(), sid, proto.OutMessage{Status: st, Note: note}) } fail := func(err error) { log.Printf("ssh %s: %v", sid, err) status(proto.SSHErr, err.Error()) a.postOut(context.Background(), sid, proto.OutMessage{Data: b64.EncodeToString([]byte("\x1b[31m" + err.Error() + "\x1b[0m\r\n"))}) a.postOut(context.Background(), sid, proto.OutMessage{Exit: true}) } if t.Port == 0 { t.Port = 22 } addr := net.JoinHostPort(t.Host, strconv.Itoa(t.Port)) auth, err := sshAuth(t) if err != nil { fail(err) return } status(proto.SSHDial, "подключение к "+addr) d := net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second} conn, err := d.DialContext(parent, "tcp", addr) if err != nil { fail(fmt.Errorf("не подключиться к %s: %v", addr, err)) return } status(proto.SSHAuth, "вход как "+t.User) var fp string _ = conn.SetDeadline(time.Now().Add(30 * time.Second)) c, chans, reqs, err := ssh.NewClientConn(conn, addr, &ssh.ClientConfig{ User: t.User, Auth: auth, HostKeyCallback: a.hostKeyCheck(&fp), Timeout: 15 * time.Second, }) if err != nil { conn.Close() msg := strings.TrimPrefix(err.Error(), "ssh: handshake failed: ") if strings.Contains(msg, "unable to authenticate") { msg = "сервер не принял вход как " + t.User + " - проверьте пароль или ключ" } fail(errors.New(msg)) return } _ = conn.SetDeadline(time.Time{}) client := ssh.NewClient(c, chans, reqs) sess, err := client.NewSession() if err != nil { client.Close() fail(fmt.Errorf("сессия не открылась: %v", err)) return } modes := ssh.TerminalModes{ssh.ECHO: 1, ssh.TTY_OP_ISPEED: 115200, ssh.TTY_OP_OSPEED: 115200} if err := sess.RequestPty("xterm-256color", 24, 80, modes); err != nil { sess.Close() client.Close() fail(fmt.Errorf("терминал не выделен: %v", err)) return } stdin, _ := sess.StdinPipe() pr, pw := io.Pipe() sess.Stdout = pw sess.Stderr = pw if err := sess.Shell(); err != nil { sess.Close() client.Close() fail(fmt.Errorf("оболочка не запустилась: %v", err)) return } sh := &sshShell{client: client, sess: sess, stdin: stdin, out: pr} go func() { _ = sess.Wait(); pw.Close() }() // Keep-alives, so a dead host is noticed and the session ends. go func() { tk := time.NewTicker(30 * time.Second) defer tk.Stop() for range tk.C { if _, _, err := client.SendRequest("keepalive@openssh.com", true, nil); err != nil { sh.Close() return } } }() log.Printf("ssh %s: %s@%s up (host key %s)", sid, t.User, addr, fp) status(proto.SSHOK, t.User+"@"+addr+" · "+fp) a.bridgeStream(parent, sid, sh, sh.Resize) log.Printf("ssh %s: ended", sid) }