feat: SSH terminal - the agent connects by SSH to another host (password, key file or its own keys) and the shell opens in a tab with the connection's progress shown; host keys trusted on first use, a changed one refused

This commit is contained in:
redtea committed 2026-10-14 21:20:18 +03:00
1 parent 5a204ae9bb
commit 5a4dc596d5
7 files changed
+515 -21

No files matched your search

+4
View File
@@ -276,6 +276,10 @@ func (a *Agent) dispatch(ctx context.Context, msg proto.ControlMessage) {
go a.runForward(ctx, msg.SessionID, msg.Port)
case proto.CtrlTask:
go a.runTask(ctx, msg.TaskID)
case proto.CtrlOpenSSH:
if msg.SSH != nil {
go a.runSSH(ctx, msg.SessionID, *msg.SSH)
}
}
}
+238
View File
@@ -0,0 +1,238 @@
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)
}