日本語版はこちら

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