Fire Engine

消防士→ITエンジニア→研究者

ユーザ名から特定したホストにコマンドを実行するSSHプロキシを書いてみる

今回は、勉強のために簡単なSSHプロキシサーバを実装してみました。
動作としては、ユーザがプロキシサーバに対してSSH接続した際に、ユーザ名からプロキシ先ホストを動的に決定し、SSH接続します。そして、接続したホストに対してhostnameコマンドを実行し、実行結果をクライアント側が受け取るという感じのものです。

やりたいこと

今回の実装はまだ実用的なものではありませんが、最終的にはSSHのユーザ名ベースで接続先のバックエンドを切り替えられるSSHプロキシサーバを構築したいと思っています。
イメージは下のような感じです。

f:id:hirotsuru314:20180722151037p:plain

これは一見ProxyCommandなどを使って踏み台サーバ経由でSSH接続をしているだけのように見えるのですが、大きな違いは ユーザ側が接続先サーバのことを全く意識しない ことです。
ProxyCommandの場合、ユーザ側が接続先サーバと踏み台サーバの情報を知っていますが、私がやりたいことは、ユーザはSSHプロキシサーバにSSH接続するだけで、勝手に特定のホストに接続が振り分けられる、というものです。

実際にこのような動作をするSSHプロキシサーバはOSSとして存在します。

github.com

このsshpiperは非常によくできていて、ユーザと接続先サーバの紐付け情報をMySQLで管理して、SSH接続をトリガーに接続先ホストの情報をDBからSELECTするような処理も実装されています。
一方で、DBに投げるクエリがコードに直書きされていて、使う側がテーブル構成等を合わせないといけなかったり、若干柔軟性に欠ける実装になっています。

コード

github.com

package main

import (
    "github.com/gliderlabs/ssh"
    gossh "golang.org/x/crypto/ssh"
    "log"
    "bytes"
    "errors"
    "io"
    "strings"
)

func findUpstreamByUsername(username string) (string, error) {
    if username == "tsurubee" {
        return "host-tsurubee", nil
    } else if username == "bob" {
        return "host-bob", nil
    }
    return "", errors.New(username + "'s host is not found!")
}

func main() {
    ssh.Handle(func(sess ssh.Session) {
        username := sess.User()
        upstream, err := findUpstreamByUsername(sess.User())
        if err != nil {
            log.Fatal(err.Error())
        }
        log.Printf("Connecting for %s by %s\n", upstream, username)

        config := &gossh.ClientConfig{
            User: username,
            Auth: []gossh.AuthMethod{
                gossh.Password("test"),
            },
            HostKeyCallback: gossh.InsecureIgnoreHostKey(),
        }

        clientConn, err := gossh.Dial("tcp", upstream + ":22", config)
        if err != nil {
            panic(err)
        }
        defer clientConn.Close()

        usess, err := clientConn.NewSession()
        if err != nil {
            panic(err)
        }
        defer usess.Close()

        var b bytes.Buffer
        usess.Stdout = &b
        if err := usess.Run("hostname"); err != nil {
            log.Fatal("Failed to run: " + err.Error())
        }
        r := strings.NewReader(b.String())
        io.Copy(sess, r)
    })

    log.Println("Starting ssh server on port 2222")
    ssh.ListenAndServe(":2222", nil)
}

検証用にDocker環境を用意したのでまずはそちらを立ち上げます。

docker-compose up

これでローカルホストの2222ポートでプロキシサーバが立ち上がり、プロキシ先ホストとして、host-tsurubeehost-bobが立ち上がります。
すると、下記のように、ユーザ名tsurubeeでSSH接続したときは、プロキシサーバ経由で、tsurubee用ホスト(host-tsurubee)でhostnameコマンドを実行した結果がクライアント側に出力され、bobユーザのときはbob用ホスト(host-bob)のhostnameの結果が返ってきています。

$ ssh tsurubee@127.0.0.1 -p 2222
host-tsurubee

$ ssh bob@127.0.0.1 -p 2222
host-bob

このように同じローカルホストの2222番に接続してもユーザ名を元にプロキシされるサーバが振り分けられています。

今回、ユーザ名からプロキシ先ホストを特定するfindUpstreamByUsername関数に簡単に条件分岐を書きましたが、本来この処理はDBから取得するなり、APIサーバから取得するなりしておきたいところです。

また、SSHサーバの構築には、gliderlabs/sshを使いました。これを使うと簡単に指定のポートでListenするSSHサーバを立てられるので便利です。

gliderlabs/sshでListenしたSSHサーバにリクエストがきて、セッションが確立されると、ハンドラーが呼ばれるため、そのハンドラーはプロキシ先に対するSSHクライアントとして動作するように書いています。

最後に

本来は接続先のサーバに対して、ターミナルからサーバにログインした時のように対話的に操作できるようにしたいんだけど、いまいちセッションとかコネクションの取り回しが理解しきれていないため勉強中。
しかし、中途半端でも実装してみると、学びは多かったので、コードファーストで学んでいくスタイルは効率が良さそうに思えた。
もうちょいプロトコルに対する理解や、Golangの抽象化レイヤーへの理解を深めていって、いい感じに書けるようにしていきたい。