y1ban.com

Line Editor in Go

日本語版はこちら

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:

  • Shows prompt by w.WriteString(prompt)
  • Takes an input line by l, _, err := r.ReadLine()
  • Echoes back the input line w.WriteString("\r\nYou've typed: " + string(l) + "\n")
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:

  • It doesn’t show editing states
  • Ctrl-d won’t terminate the session
  • Return key doesn’t work

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:

  • Instanciates a line editor t := terminal.NewTerminal(conn, "> ")
  • Receives a user input line l, err := t.ReadLine()
  • Echoes back the line t.Write([]byte("You've typed: " + string(l) + "\r\n"))

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

“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/.