Line Editor in Go

  • Ichiban
  • CLI
  • SSH
  • Golang

日本語版はこちら

Introduction

It's easy to write an SSH server in Go. In most cases, your SSH server deals with command line inputs from users. Then, you'll need a piece of software called a line editor which converts key strokes into string. In Go, it's golang.org/x/crypto/ssh/terminal.

Write an SSH Server

Let's write an SSH server. You'll need golang.org/x/crypto/ssh to write an SSH server in Go.

For example, we'll consider a simple case which echoes back your input lines. It's around 120 lines so you don't look into details. Here's a summary of it:

package main

import (
    "bufio"
    "fmt"
    "io/ioutil"
    "log"
    "net"
    "os/exec"

    "golang.org/x/crypto/ssh"
)

func main() {
    key, err := privateKey()
    if err != nil {
        log.Fatalf("failed to load private key: %v", err)
    }

    config := &ssh.ServerConfig{NoClientAuth: true}
    config.AddHostKey(key)

    listener, err := net.Listen("tcp", "0.0.0.0:2022")
    if err != nil {
        log.Fatalf("failed to listen on 2022: %v", err)
    }

    for {
        tcp, err := listener.Accept()
        if err != nil {
            log.Printf("failed to accept tcp connection: %v", err)
            continue
        }

        _, chans, reqs, err := ssh.NewServerConn(tcp, config)
        if err != nil {
            log.Printf("failed to handshake: %v", err)
            continue
        }

        go ssh.DiscardRequests(reqs)
        go handleChannels(chans)
    }
}

func handleChannels(chans <-chan ssh.NewChannel) {
    for c := range chans {
        go handleChannel(c)
    }
}

func handleChannel(c ssh.NewChannel) {
    if t := c.ChannelType(); t != "session" {
        msg := fmt.Sprintf("unknown channel type: %s", t)
        c.Reject(ssh.UnknownChannelType, msg)
        return
    }

    conn, _, err := c.Accept()
    if err != nil {
        log.Printf("failed to accept channel: %v", err)
        return
    }
    defer conn.Close()

    r := bufio.NewReader(conn)
    w := bufio.NewWriter(conn)
    prompt := "> "

    for {
        if _, err := w.WriteString(prompt); err != nil {
            log.Printf("failed to write: %v", err)
            return
        }

        if err := w.Flush(); err != nil {
            log.Printf("failed to flush: %v", err)
            return
        }

        l, _, err := r.ReadLine()
        if err != nil {
            log.Printf("failed to read: %v", err)
            return
        }

        if _, err := w.WriteString("\r\nYou've typed: " + string(l) + "\n"); err != nil {
            log.Printf("failed to write: %v", err)
            return
        }

        if err := w.Flush(); err != nil {
            log.Printf("failed to flush: %v", err)
            return
        }
    }
}

func privateKey() (ssh.Signer, error) {
    b, err := privateKeyBytes()
    if err != nil {
        return nil, err
    }

    return ssh.ParsePrivateKey(b)
}

func privateKeyBytes() ([]byte, error) {
    if key, err := ioutil.ReadFile("example.rsa"); err == nil {
        return key, err
    }

    if err := exec.Command("ssh-keygen", "-f", "example.rsa", "-t", "rsa", "-N", "").Run(); err != nil {
        return nil, err
    }

    return ioutil.ReadFile("example.rsa")
}

Now try this SSH server. go run [file name] will start the server and open a new terminal, type ssh -p2022 localhost to open a client. You'll find it's mulfunctioning very early:

In other words, it's not working at all. These problems are because we're not using a line editor.

Add a line editor to the SSH server

A line editor is a kind of libraries which displays editing states while user is typing and once user hits return key, sends back the confirmed input line to the host program. There're well-known C implementations such as readline, libedit, and linenoise.

The SSH server above doesn't display editing states because it doesn't refresh the displayed contents while user's typing. Ctrl-d doesn't work because it's not capable of recognize it yet. Also, It is a return (CR 0x0D) what it recieves instead of an end of line (LF 0x0A or CR+LF 0x0D, 0x0A depending on the platform). Thus, r.ReadLine() can't detect confirmation of input lines. In other words, it is key strokes what the SSH server is receiving, not strings. A line editor can retrieve strings out of key strokes.

Now let's add a line editor to the SSH server. It's time for golang.org/x/crypto/ssh/terminal. The example code below is around 100 lines so you can skip. The points are:

It displays the prompt automatically so we don't have to care.

package main

import (
    "fmt"
    "io/ioutil"
    "log"
    "net"
    "os/exec"

    "golang.org/x/crypto/ssh"
    "golang.org/x/crypto/ssh/terminal"
)

func main() {
    key, err := privateKey()
    if err != nil {
        log.Fatalf("failed to load private key: %v", err)
    }

    config := &ssh.ServerConfig{NoClientAuth: true}
    config.AddHostKey(key)

    listener, err := net.Listen("tcp", "0.0.0.0:2022")
    if err != nil {
        log.Fatalf("failed to listen on 2022: %v", err)
    }

    for {
        tcp, err := listener.Accept()
        if err != nil {
            log.Printf("failed to accept tcp connection: %v", err)
            continue
        }

        _, chans, reqs, err := ssh.NewServerConn(tcp, config)
        if err != nil {
            log.Printf("failed to handshake: %v", err)
            continue
        }

        go ssh.DiscardRequests(reqs)
        go handleChannels(chans)
    }
}

func handleChannels(chans <-chan ssh.NewChannel) {
    for c := range chans {
        go handleChannel(c)
    }
}

func handleChannel(c ssh.NewChannel) {
    if t := c.ChannelType(); t != "session" {
        msg := fmt.Sprintf("unknown channel type: %s", t)
        c.Reject(ssh.UnknownChannelType, msg)
        return
    }

    conn, _, err := c.Accept()
    if err != nil {
        log.Printf("failed to accept channel: %v", err)
        return
    }
    defer conn.Close()

    t := terminal.NewTerminal(conn, "> ")

    for {
        l, err := t.ReadLine()
        if err != nil {
            log.Printf("failed to read: %v", err)
            return
        }

        if _, err := t.Write([]byte("You've typed: " + string(l) + "\r\n")); err != nil {
            log.Printf("failed to write: %v", err)
            return
        }
    }
}

func privateKey() (ssh.Signer, error) {
    b, err := privateKeyBytes()
    if err != nil {
        return nil, err
    }

    return ssh.ParsePrivateKey(b)
}

func privateKeyBytes() ([]byte, error) {
    if key, err := ioutil.ReadFile("example.rsa"); err == nil {
        return key, err
    }

    if err := exec.Command("ssh-keygen", "-f", "example.rsa", "-t", "rsa", "-N", "").Run(); err != nil {
        return nil, err
    }

    return ioutil.ReadFile("example.rsa")
}

By installing a line editor, now we can see editing states as we type, terminate the session with Ctrl-d, and retrieve input lines.

Success!

balloon.png

balloon.png

"Gopher Stickers" by Takuya Ueda is licensed under CC BY 3.0

Conclusion

I explained you can easily add a line editor to your SSH server with golang.org/x/crypto/ssh/terminal.

This article was originally written for the first day of Go (その2) Advent Calendar 2016 in Japanese. When I booked the date, I announced it'll be about some caveat on starting Go.

I wrote this article because I mistakenly made my own version of line editor without knowing there's a de facto implementation.

So, here's a caveat: the awesome library you came up with oftentimes has a better alternative under golang.org/x/.

Recent Work

postmore

Postmore

Brand Ambassador Program Management App.

Linesqueak

Linenoise clone in Go

Get In Touch

Do you have a project you want to talk with me, or just hang out? Feel free to drop me a line!

Email yichiban@gmail.com