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)
}