SSHポートフォワーディング機能をGoで簡単に実装してみた

この記事はRetty Advent Calendar 2020の24日目の記事です。

adventar.org

前書き

この記事は

  • 普段の開発においてSSHポートフォワーディングを利用している方
  • 単純にGoの実装に興味を持っている方

向けです。

SSHポートフォワーディングのやり方

SSHポートフォワーディングは -L / -R 2つのオプションで実現できますが、それぞれ意味が違います。

-L - Local Forwarding

LocalからのリクエストをSSH Tunnel経由でRemoteへアクセスする際に使います。

# Localの13306ポートへのリクエストをgateway-server経由でmysql-serverの3306ポートへフォワーディングする
ssh -L 13306:mysql-server:3306 user@gateway-server

-R - Remote Forwarding

RemoteからのリクエストをSSH Tunnel経由でLocalからアクセスさせる際に使います。

# gateway-serverの13306ポートへのリクエストをLocal経由でmysql-serverの3306ポートへフォワーディングする
ssh -R 13306:mysql-server:3306 user@gateway-server

なぜ自前で実装したいと思ったのか

普段の開発において、SSHポートフォワーディングの-L - Local Forwarding(-R - Remote Forwardingはあまり利用しないので実装の方も割愛しています)をよく使うのですが、 sshコマンドをいちいち立ち上げるのは面倒でした。
ポートフォワーディングをssh configで行うこともできますが、複数のプロジェクトをやってると、 それぞれのプロジェクトにおいて必要となる設定が異なるので、ssh configの管理が面倒になります。

なので、もしポートフォワーディングの設定を各プロジェクトのコードベースと一緒に管理できるなら便利じゃないかと思ったのです。

なぜGoで実装したのか

一番の理由はやはりGoが好きだからです。
そして、Goは ssh · pkg.go.dev packageがあってSSH Clientの実装が簡単にできるからです。

どう実装するのか

f:id:rettydev:20201223192019p:plain

上の図の通りですが、簡単にコードを貼りながら説明していきます。
(* 説明しやいため、下記のsshコマンド同等の機能を実現する例にしています。)

ssh -L 13306:mysql-server:3306 -i ~/.ssh/id_rsa user@gateway-server
  • Gatewayサーバに接続するためSSH Clientを初期化する
    // private key fileをパースしssh.AuthMethodを作る
    buf, _ := ioutil.ReadFile("~/.ssh/id_rsa") // ~が読めないので絶対パスに変換してください
    k, _ := ssh.ParsePrivateKey(buf)
    auth := []ssh.AuthMethod{ssh.PublicKeys(k)}
    // 必要なSSH Client Configを作る
    cfg := &ssh.ClientConfig{
        User:            "user",
        Auth:            auth,
        HostKeyCallback: ssh.InsecureIgnoreHostKey(), // ドキュメントは It should not be used for production code. 書かれていて、ツールなので一旦よしとしよう
        Timeout:         2 * time.Second,             // 2秒の接続タイムアウトを設定する
    }
    // gateway-serverの22ポートへ接続したSSH Clientを作る
    sshClient := ssh.Dial("tcp", "gateway-server:22", cfg)
  • LocalからのリクエスをListenする
    // localhostの13306ポートをListenする
    listener, _ := net.Listen("tcp", "localhost:13306")
    for {
        // for-loopで来るリクエストを全部受け取る
        localConn, _ := listener.Accept()
        ...
    }
  • Localからの接続が成立すると、SSH ClientでMySQLサーバへDialし接続を作成する
    for {
        ...
        // mysql-serverの3306ポートへ接続する
        remoteConn, err := sshClient.Dial("tcp", "mysql-server:3306")
    }
  • Localからの接続とMySQL Serverへの接続の間でデータを相互コピーする
    var wg sync.WaitGroup
    wg.Add(2)

    go func() {
        defer wg.Done()
        // localConnから読み込んだデータを全部remoteConnへ書き込む
        _, _ := io.Copy(remoteConn, localConn)
    }()

    go func() {
        defer wg.Done()
        // remoteConnから読み込んだデータを全部をlocalConnへ書き込む
        _, _ := io.Copy(localConn, remoteConn)
    }()

    wg.Wait()

ものすごくシンプルなコードですが、これでLocalの13306ポートに接続すると、Gatewayサーバ経由して、リモートにあるMySQLサーバへの接続ができるようになります。

Yamlファイルでポートフォワーディングの設定をできるように

好みの問題もありますが、ConfigファイルのフォーマットはYamlにしています。
YamlのパースはGo内製のpackageがないので、 GitHub - go-yaml/yaml: YAML support for the Go language. を利用します。

フォーマットは下記のような感じです。

key_files:
  - ~/.ssh/id_rsa
gateways:
  - server: user@gateway-server:22
    tunnels:
      - mysql-server:3306 -> 127.0.0.1:13306
      - redis-server:6379 -> 127.0.0.1:16379

これで、各プロジェクトのコードベースにYamlのConfigファイルをおいて、ポートフォワーディング利用したいとなったらそのConfigファイルでコマンド起動するだけで良くなります。
(* 私個人は各プロジェクトのルートに .tunnel.yaml という名前でConfigファイルを置いていて、gitのglobal設定でgitのトラッキングから除外しています。)

ツールとして実装する上、工夫したところ

コマンドの終了をコントロールする

context.Context をうまく利用する

    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    // SIGINT で全終了する
    go func() {
        defer cancel()
        signCh := make(chan os.Signal, 1)
        signal.Notify(signCh, os.Interrupt)
        <-signCh
    }()

内部実装は全部呼び出し側からctxを受け取って、接続作成/終了と待つ必要のある処理を全部ctxで制御する

func (t *tunnel) Forward(ctx context.Context) { ... }
func (t *tunnel) startAccept(ctx context.Context, sshClient *reconnectableSSHClient, bindListener *closableListener) { ... }

func (c *reconnectableSSHClient) Dial(ctx context.Context, n, addr string) (net.Conn, error) { ... }
func (c *reconnectableSSHClient) KeepAlive(ctx context.Context) { ... }
func (c *reconnectableSSHClient) reconnect(ctx context.Context) error { ... }

SSH接続が切断された場合の処理

パソコンがスリープモードに入ったり、Wi-Fiが切断したりすると、SSH Clientが維持している接続が切断されることがあります。(この点においてsshコマンドも同じ問題があります。)
なので、SSH接続のKeepAlive処理が必要になります。
具体的に、keepalive@openssh.com へ定期的に空のリクエストを送信し、送信が失敗すると接続を作り直すようにすれば解決します。

どんな時に便利なのか

ポートフォワーディングは様々な場合で便利ですが、私個人は普段の開発で主に以下の時に使用しています。

  • アプリケーションがLocalからRemote(プライベートネットワーク)にある複数のデータソース(MySQL, Redis, etc.)へ接続したい時
key_files:
  - ~/.ssh/id_rsa
gateways:
  - server: user@gateway-server:22
    tunnels:
      - slave-mysql-server:3306 -> 127.0.0.1:13306
      - master-mysql-server:3306 -> 127.0.0.1:13307
      - session-redis-server:6379 -> 127.0.0.1:16379
      - cache-redis-server:6379 -> 127.0.0.1:16380
  • マイクロサービスアーキテクチャーを用いた開発において、アプリケーションがLocalからRemote(プライベートネットワーク)にある複数のマイクロサービスへ接続したい時
key_files:
  - ~/.ssh/id_rsa
gateways:
  - server: user@gateway-server:22
    tunnels:
      - service-A:50051 -> 127.0.0.1:150051
      - service-B:50051 -> 127.0.0.1:150052
      - service-C:50051 -> 127.0.0.1:150053

最後に

今回紹介したツールのソースコードは既にGitHubに公開しているので、ご興味ある方はぜひ読んでみてください。
Go製のツールなので、Goコマンドが入っている環境だったら簡単にインスートもできるので、ぜひ試してみてください。