Go ssh Client
I wrote in 2018 (My First Experience with Go) how writing a tool in Go to run commands over ssh was harder than it was in Python. It wasn't easy but I finally managed to write the tool this year.
There are two main ways I have to use an ssh client written in Go. The first is the easiest option; run a single command and quit. The second is to start a shell on a remote server and use it interactively. This is the trickier one.
The second feature I need to support is optionally connect to a target host behind a bastion or jump host.
Both these are easily done with OpenSSH. However, there are times when that is not sufficient and we must integrate ssh into a tool written by us.
We have to decide which authentication mechanism to use. I prefer using ssh keys as it is easier to setup and keep secure at the same time. Using passwords is often frowned upon but I have come across systems where that's the only option available. In this post I'll use the password method, leaving the secure management of the password up to you as an exercise.
Configuration struct
type config struct { Address string Password string Port string User string Bastion bastionConfig } type bastionConfig struct { Address string Password string Port string User string } cfg := config{ Address: "1.2.3.4", Password: "mysecret", Port: "22", User: "aikchar", Bastion: bastionConfig{ Address: "2.3.4.5", Password: "mysecurestring", Port: "22", User: "aikchar", } }
Native ssh Library
In this design the password is entered automatically by Go. We no longer need to manually type it in.
ssh to Target Host
targetCfg := ssh.ClientConfig{ User: cfg.User, Auth: []ssh.AuthMethod{ssh.Password(cfg.Password)}, HostKeyCallback: ssh.InsecureIgnoreHostKey(), } addr := fmt.Sprintf("%s:%s", cfg.Address, cfg.Port) conn, err := ssh.Dial("tcp", *addr, &targetCfg) if err != nil { fmt.Println("Dial failed to target host") log.Fatal(err) } defer conn.Close() sesh, err := conn.NewSession() if err != nil { log.Fatal("Session not created ", err) } defer sesh.Close() getPTY(sesh) runCmd("hostname") interactiveShell(sesh)
ssh to Target Host Via Bastion Host
targetCfg := ssh.ClientConfig{ User: cfg.User, Auth: []ssh.AuthMethod{ssh.Password(cfg.Password)}, HostKeyCallback: ssh.InsecureIgnoreHostKey(), } bastionCfg := ssh.ClientConfig{ User: cfg.Bastion.User, Auth: []ssh.AuthMethod{ssh.Password(cfg.Bastion.Password)}, HostKeyCallback: ssh.InsecureIgnoreHostKey(), } bastion_ := fmt.Sprintf("%s:%s", cfg.Bastion.Address, cfg.Bastion.Port) bClient, err := ssh.Dial("tcp", bastion_, &bastionCfg) if err != nil { fmt.Println("Dial failed to jump host") log.Fatal(err) } // Dial a connection to the service host, from the bastion target_ := fmt.Sprintf("%s:%s", cfg.Address, cfg.Port) conn, err := bClient.Dial("tcp", target_) if err != nil { fmt.Println("Dial failed to target host") log.Fatal(err) } defer conn.Close() ncc, chans, reqs, err := ssh.NewClientConn(conn, target_, &targetCfg) if err != nil { fmt.Println("NewClientConn failed on target host") log.Fatal(err) } defer ncc.Close() tClient := ssh.NewClient(ncc, chans, reqs) sesh, err := tClient.NewSession() if err != nil { log.Fatal("Session not created ", err) } defer sesh.Close() getPTY(sesh) interactiveShell(sesh)
Get PTY
func getPTY(sesh *ssh.Session) { modes := ssh.TerminalModes{ ssh.ECHO: 0, // disable echoing ssh.TTY_OP_ISPEED: 14400, // input speed = 14.4kbaud ssh.TTY_OP_OSPEED: 14400, // output speed = 14.4kbaud } if err := sesh.RequestPty("xterm", 40, 80, modes); err != nil { log.Fatal("failed to get PTY: ", err) } }
Run Command
func runCmd(cmd string, sesh *ssh.Session) { co, err := sesh.CombinedOutput(cmd) if err != nil { log.Fatal("failed to run command: ", err) } fmt.Println(string(co[:])) }
Start Interactive Shell
func interactiveShell(sesh *ssh.Session) { sesh.Stdout = os.Stdout sesh.Stderr = os.Stderr in, _ := sesh.StdinPipe() if err := sesh.Shell(); err != nil { log.Fatalf("failed to start shell: %s", err) } for { reader := bufio.NewReader(os.Stdin) str, _ := reader.ReadString('\n') fmt.Fprint(in, str) } }
Wrap OpenSSH in Go
This is where instead of using golang.org/x/crypto/ssh library we call OpenSSH with os/exec. The trick is to use Run() to start OpenSSH client and attach all pipes to the process. To exit or quit you must use ctrl+d. You must enter the password manually.
ctx := context.Background() bastionStr := "" if cfg.Bastion.Address != "" { bastionStr = fmt.Sprintf("-o ProxyJump=%s@%s:%s", cfg.Bastion.User, cfg.Bastion.Address, cfg.Bastion.Port) } cmdStr := fmt.Sprintf("ssh -t -o StrictHostKeyChecking=no %s -o Port=%s %s@%s", bastionStr, cfg.Port, cfg.User, cfg.Address) cmd := exec.CommandContext(ctx, "/bin/bash", "-c", cmdStr) cmd.Stdin = os.Stdin cmd.Stderr = os.Stderr cmd.Stdout = os.Stdout err := cmd.Run() if err != nil { log.Fatalf("cannot start process: %s", err) }