Ruby Net::SSHで多段ssh（ポートフォワード/トンネリング）をプログラマブルにやる

## Goal

- sshをプログラマブルにやる
- 踏み台サーバ経由でsshする
- **秘密鍵をメモリ上におき、ディスクにおかなくていいようにする**
- （おまけ）最後にspecinfra, itamaeをこの方式でつかえるようにする

## Motivation

秘密鍵の管理がややこしい。案件が増えるにつれてどんどん増えていく。踏み台サーバの鍵と目的サーバの鍵が異なることもあり、サーバ構築時（ユーザ作成前）と構築後でつかう鍵が異なることもある。鍵を担当者に共有するのもややこしい。

少なくとも自分でコントロールできる案件については、構築に関係する鍵をプログラマブルに管理し、構築自体動的にやりたいと思うようになった。

## Mechanism

### key_data

ふつうコマンドラインのオプションでは`-i`など、鍵の**パス**を指定するのが一般的だが、`Net::SSH`には `key_data` というオプションで、**文字列**で直接秘密鍵を指定する仕組みがある。 [^1] これをつかえば、秘密鍵をディスク上に保存することなくsshできる。

```ruby
require 'net/ssh'

key_data = <<-EOS
-----BEGIN RSA PRIVATE KEY-----
MIIEowxxxxxxxx.....
-----END RSA PRIVATE KEY-----
EOS

Net::SSH.start('x.x.x.x', 'ec2-user',
  port: 22,
  key_data: key_data
) do |ssh|
  ssh.exec!("hostname")
end

# => "ip-y-y-y-y\n"
```

踏み台サーバごしに `key_data` をつかえれば今回の要件をクリアできるという寸法。

### ProxyCommand

capistranoで踏み台経由のデプロイするときは、今までProxyCommandをつかっていた。capistranoは裏でNet::SSHをつかってるので、`Net::SSH::Proxy::Command`をつかってプロキシできる。

今回の多段sshも、はじめこれと同じ要領でできるような気がしていたが、どうもできない。

[net-ssh/command.rb at v5.2.0 · net-ssh/net-ssh · GitHub](https://github.com/net-ssh/net-ssh/blob/v5.2.0/lib/net/ssh/proxy/command.rb)

`Net::SSH::Proxy::Command`は引数に渡されたコマンドを、OSコマンドライン上で実行しているにすぎない（`IO.popen`）。つまり `Net::SSH` がもっているプログラマブルな鍵管理の仕組み（`key_data`など）を適用できない。しかしコマンドラインのsshでは秘密鍵を直接文字列で渡す仕組みはない。（コマンドライン実行されたものは履歴に残るので仮にできたとしてもやるべきではない）

```ruby
require 'net/ssh'
require 'net/ssh/proxy/command'

key_data = <<-EOS
-----BEGIN RSA PRIVATE KEY-----
MIIEowxxxxxxxx.....
-----END RSA PRIVATE KEY-----
EOS

Net::SSH.start('x.x.x.x', 'ec2-user',
  port: 22,
  key_data: key_data,
  # ↓のコマンドに踏み台サーバログインのための鍵のパスを指定する必要がある
  # 踏み台へのアクセス時にはkey_dataに指定した鍵はつかわれない
  proxy: Net::SSH::Proxy::Command.new('ssh -W %h:%p metheglin@a.a.a.a -i ~/.ssh/id_rsa'),
) do |ssh|
  ssh.exec!("hostname")
end

# => "ip-y-y-y-y\n"
```

つまり、ProxyCommandつかうなら必ず秘密鍵がファイルとしてディスク上に配置されていなければいけない。今回の要件ではProxyCommandはつかえないことになる。

### net-ssh-gateway

じつはわたしは、多段sshについて理解がとぼしく、軽く混乱状態にあった。ProxyCommandができないとわかると混乱きわまってあきらめかけた。
一度冷静になって、できないわけないと思い直し、歯を食いしばって調べ直した結果以下のgemを見つけた。最近歯医者に行ったからくいしばり力があがってたのかもしれない。

[GitHub - net-ssh/net-ssh-gateway: A gateway class for tunneling connections via SSH over a forwarded port](https://github.com/net-ssh/net-ssh-gateway)

READMEにはactiveに開発されてないとコメントがあるが、中身はすごいシンプルで、214行くらいしかない。[^2] このgemは、シンプルにローカルフォワード[^3]をおこなっている。適当なポートがはらいだされて[^4]、それがフォワードの窓口となる。プログラマはそのポートについて意識しなくてもいい。

## Sample Code

net-ssh-gatewayをつかって、秘密鍵をディスクにおかない多段sshは以下のようにできる。

```bash
gem install net-ssh-gateway
```

```ruby
require 'net/ssh/gateway'

key_data = <<-EOS
-----BEGIN RSA PRIVATE KEY-----
MIIEowxxxxxxxxxxxx...
-----END RSA PRIVATE KEY-----
EOS

gateway = Net::SSH::Gateway.new('x.x.x.x', 'ec2-user', key_data: key_data)
ssh = gateway.ssh('a.a.a.a', 'ec2-user', 
  key_data: key_data
)

ssh.exec!("hostname; pwd; id")
ssh.close
gateway.close(ssh.transport.port)
```

`Net::SSH::Gateway.new`した時点で、踏み台への接続が確立される。
その後 `gateway.ssh()` するともういっこ接続が確立される。

ここでは踏み台サーバとターゲットサーバで同じ`key_data`をつかっているが、別の鍵にすることも可能。

### specinfra

specinfraでこのgatewayごしのssh接続をどうつかうか？

specinfraではハックポイントが用意されていて、カスタムのssh接続をつかえるようになっていた。[^5]
上記のコード `gateway.ssh()` でつくった接続 `ssh` を以下のように指定すればOK。

``` ruby
specinfra = Specinfra::Backend::Ssh.new(
  request_pty: true,
  disable_sudo: false,
  ssh: ssh,
  ssh_options: {
    user: 'ec2-user',
  }
)

specinfra.run_command("hostname; pwd; id")

# ssh.close
# gateway.close(ssh.transport.port)
```

## itamae

itamaeはコマンドライン上の実行を主に想定してるっぽいので、大胆なハックができない。
以下のようにプログラマブルで実行用のRunnerをつくることでしのぐ。
上記specinfraのコードで作成した変数`specinfra`を指定してつかう。

```ruby
recipe = File.expand_path("recipes/ping.rb", __dir__)
Itamae::InlineRunner.run(
  [recipe],
  specinfra: specinfra,
  node: {test: 1234},
)
```

```ruby
module Itamae
  class InlineRunner < Itamae::Runner
    class << self
      def run(recipe_files, options)
        Itamae.logger.info "Starting Itamae... #{options[:dry_run] ? '(dry-run)' : ''}"

        backend = Backend::SshInline.new(options)
        runner = self.new(backend, options)
        runner.load_recipes(recipe_files)
        runner.run

        runner
      end
    end

    private
    def create_node
      return @options[:node] if @options[:node]
      super
    end
  end

  module Backend
    class SshInline < Base
      def initialize(options)
        @options = options
        @backend = options[:specinfra]
        @executed_commands = []
      end

      def disable_sudo?
        !@options[:sudo]
      end
    end
  end
end
```

## Environment

```
% ruby -v
ruby 2.5.1p57 (2018-03-29 revision 63029) [x86_64-linux]
```

```
[2] pry(main)> Net::SSH::Version::STRING
=> "5.2.0"
[3] pry(main)> Net::SSH::Gateway::VERSION
=> "2.0.0"
[4] pry(main)> Specinfra::VERSION
=> "2.82.4"
[5] pry(main)> Itamae::VERSION
=> "1.10.6"
```

[^1]: [net-ssh/ssh.rb at v5.2.0 · net-ssh/net-ssh · GitHub](https://github.com/net-ssh/net-ssh/blob/v5.2.0/lib/net/ssh.rb#L147)
[^2]: [net-ssh-gateway/gateway.rb at v1.2.0 · net-ssh/net-ssh-gateway · GitHub](https://github.com/net-ssh/net-ssh-gateway/blob/v1.2.0/lib/net/ssh/gateway.rb)
[^3]: [sshポートフォワーディング - Qiita](https://qiita.com/mechamogera/items/b1bb9130273deb9426f5#%E3%83%AD%E3%83%BC%E3%82%AB%E3%83%AB%E3%83%95%E3%82%A9%E3%83%AF%E3%83%BC%E3%83%89)
[^4]: [net-ssh-gateway/gateway.rb at v1.2.0 · net-ssh/net-ssh-gateway · GitHub](https://github.com/net-ssh/net-ssh-gateway/blob/v1.2.0/lib/net/ssh/gateway.rb#L206) (自動的にポートの払い出しがおこなわれるが、番号を明示指定することもできる。)
[^5]: [specinfra/ssh.rb at v2.82.4 · mizzy/specinfra · GitHub](https://github.com/mizzy/specinfra/blob/v2.82.4/lib/specinfra/backend/ssh.rb#L113)