はじめに

GoはSSHサーバを書くのもかんたんです。ほとんどの場合、あなたのSSHサーバはユーザからのコマンド入力を受け付けるものでしょう。その場合、キー入力の列を文字列に変換するラインエディタと呼ばれるものが必要になります。Goではgolang.org/x/crypto/ssh/terminalがそれです。

SSHサーバを書く

SSHサーバを書きましょう。GoでSSHサーバを書くにはgolang.org/x/crypto/sshを使います。

ここでは例として入力行を送り返すだけの単純なSSHサーバを考えます。120行ほどあるので、読み飛ばしてもらってかまいません。重要なポイントは以下の3点だけです。

  • プロンプトを表示する w.WriteString(prompt)
  • ユーザの入力行を受け取る l, _, err := r.ReadLine()
  • 入力行を送り返す 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")
}

このSSHサーバを試してみましょう。go run [ファイル名]でサーバを起動し、別ターミナルでssh -p2022 localhostでクライアントを起動します。すぐにこれがダメであることがわかるでしょう。

  • 入力途中の状態が表示されない
  • Ctrl-dで終了しない
  • リターンキーが反応しない

つまり、ぜんぜんダメです。これらはすべて、ラインエディタが使われていないことに起因します。

SSHサーバにラインエディタを足す

ラインエディタは編集状態を画面に表示しつつユーザからのキー入力を受け取り、ユーザがリターンキーを打つと入力行を確定してプログラムに渡すという処理をするライブラリで、代表的なCでの実装にreadline、libedit、linenoiseなどがあります。

上述のSSHサーバで入力途中の状態が表示されないのは、キー入力の度に画面に表示する内容を更新していないからです。Ctrl-dが効かないのは、そのキーをSSHサーバが解釈する機能をまだ持っていないからです。そして、SSHサーバが受け取っているのは改行コード(LF 0x0AまたはCR+LF 0x0D, 0x0A)ではなく、リターンキー(CR 0x0D)であるため、r.ReadLine()では入力の確定を検知できないのです。すなわち、SSHサーバが受け取っているのはキー入力の列であり、文字列ではない、といえるでしょう。ラインエディタはキー入力の列から行単位の文字列を取り出すことができます。

SSHサーバにラインエディタを足しましょう。golang.org/x/crypto/ssh/terminalの出番です。これも100行あるので読み飛ばしてもらってかまいません。ポイントは以下の3点です。

  • ラインエディタTerminalを作成する t := terminal.NewTerminal(conn, "> ")
  • ユーザ入力行を受け取る l, err := t.ReadLine()
  • 入力行を送り返す t.Write([]byte("You've typed: " + string(l) + "\r\n"))

プロンプトの表示はTerminalがやってくれます。

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

ラインエディタを導入したことにより、入力途中の状態が逐次画面に反映され、Ctrl-dで終了することも出来ます。

サクセス

balloon.png

“Gopher Stickers” by Takuya Ueda is licensed under CC BY 3.0

おわりに

GoでSSHサーバを書く際に、golang.org/x/crypto/ssh/terminalでラインエディタをかんたんに追加できることを紹介しました。

この記事は Go (その2) Advent Calendar 2016 の1日目の記事として書かれました。そこでは「Goをはじめる際の注意点について書きます」と予告していました。

この記事はGo初心者である僕が、GoにはSSHサーバのライブラリはあるが、併用するラインエディタが不足していると早とちりして自前で書いてしまったという失敗に由来します。

Goをはじめる際の注意点です。あなたが思いついた便利なライブラリはだいたい golang.org/x/ の下にあります。