今回は、Golangのgolang.org/x/crypto/sshパッケージを使って、SSHサーバを構築してみました。
かなりミニマムな実装ですが、リモートからSSH接続して、対話的にコマンドが実行できるところまで実装しました。
コード
package main import ( "golang.org/x/crypto/ssh" "log" "net" "io/ioutil" "fmt" "os/exec" "github.com/kr/pty" "sync" "io" ) func main() { serverConfig := &ssh.ServerConfig{ NoClientAuth: true, } privateKeyBytes, err := ioutil.ReadFile("id_rsa") if err != nil { log.Fatal("Failed to load private key (./id_rsa)") } privateKey, err := ssh.ParsePrivateKey(privateKeyBytes) if err != nil { log.Fatal("Failed to parse private key") } serverConfig.AddHostKey(privateKey) listener, err := net.Listen("tcp", "0.0.0.0:2222") if err != nil { log.Fatalf("Failed to listen on 2222 (%s)", err) } log.Print("Listening on 2222...") for { tcpConn, err := listener.Accept() if err != nil { log.Fatalf("Failed to accept on 2222 (%s)", err) } sshConn, chans, reqs, err := ssh.NewServerConn(tcpConn, serverConfig) if err != nil { log.Fatalf("Failed to handshake (%s)", err) } log.Printf("New SSH connection from %s (%s)", sshConn.RemoteAddr(), sshConn.ClientVersion()) go ssh.DiscardRequests(reqs) go handleChannels(chans) } } func handleChannels(chans <-chan ssh.NewChannel) { for newChannel := range chans { go handleChannel(newChannel) } } func handleChannel(newChannel ssh.NewChannel) { if t := newChannel.ChannelType(); t != "session" { newChannel.Reject(ssh.UnknownChannelType, fmt.Sprintf("Unknown channel type: %s", t)) return } sshChannel, _, err := newChannel.Accept() if err != nil { log.Fatalf("Could not accept channel (%s)", err) return } bash := exec.Command("bash") close := func() { sshChannel.Close() _, err := bash.Process.Wait() if err != nil { log.Printf("Failed to exit bash (%s)", err) } log.Printf("Session closed") } f, err := pty.Start(bash) if err != nil { log.Printf("Could not start pty (%s)", err) close() return } var once sync.Once go func() { io.Copy(sshChannel, f) once.Do(close) }() go func() { io.Copy(f, sshChannel) once.Do(close) }() }
コードについていくつか補足します。
ローカルPCとSSHサーバのSSHセッションを確立する前に、まずTCPレイヤーのコネクションを確立する必要があります。私の中でTCPコネクションはローカルマシンとリモートマシンのソケット同士の論理的な回線というイメージで、SSHセッションというと、SSHのレイヤーすなわちアプリケーションレイヤーまで広がり、セッションの中でコネクションが管理されているイメージです。
実際のコードでは、net.ListenでTCPをListenし、Acceptして返ってきたTCPコネクションを、ssh.NewServerConnに渡すことで、認証を行った後、SSHのセッションが確立されます。
SSHのセッションが確立されると、あとはSSH経由でコマンドを実行して、その結果が対話的に表示されるようにしたいです。この対話的の部分にはio.Copyを使います。
io.Copyを使うと、io.Readerインターフェースからio.Writerインターフェースにそのままデータを渡すことができます。これを使って、擬似端末(pty)とSSHチャンネル(goのchannelとは違う、SSHのコネクションのようなもの)の間で双方向にCopyしてやると、入出力が対話的にやれます。
使い方
SSHサーバのホスト認証のために、公開鍵と秘密鍵のペアを作成し、リポジトリのルートディレクトリの置きます。
ssh-keygen -t rsa -N '' -f ./id_rsa
生成された公開鍵(id_rsa.pub)はローカルPCのknown_hosts
に以下のような感じで登録しておきます。
[localhost]:2222 ssh-rsa AAAAB3・・・・
あとは下のコマンドでDocker上にSSHサーバが起動します。
$ docker-compose up Starting gosshd_gosshd_1 ... done Attaching to gosshd_gosshd_1 gosshd_1 | ==> Installing Dependencies gosshd_1 | go get -u github.com/golang/dep/... gosshd_1 | dep ensure gosshd_1 | go run main.go gosshd_1 | 2018/07/28 12:44:31 Listening on 2222...
ローカルPCからポート2222にSSH接続すると、コンテナの中に繋がります。
$ ssh tsurubee@localhost -p 2222 root@9cd2bdaf33c0:/go/src/gosshd#
今回ユーザ認証の機能はOFFにしているのでユーザ名はなんでも大丈夫です。 あとは普通にサーバにSSHで繋いだときのようにコマンドが実行できます。
root@9cd2bdaf33c0:/go/src/gosshd# pwd /go/src/gosshd root@9cd2bdaf33c0:/go/src/gosshd# ls Gopkg.lock Makefile docker-compose.yml id_rsa.pub vendor Gopkg.toml README.md id_rsa main.go
さいごに
今回はミニマム実装なので、実用的なSSHサーバになるまでには、認証周りの実装や、 SSHのグローバルリクエストやチャネルリクエストの取り回しも実装しないといけないです。
これからやりたいのはRFCを読んでSSHプロトコルに関する理解を深めるのと、SSHのリバースプロキシを実装してみることです。
参考
GitHub - mattn/go-sshd: DEPRECATED: Please use https://github.com/gliderlabs/ssh instead