239 lines
7.1 KiB
Go
239 lines
7.1 KiB
Go
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)
|
|
}
|