expect コマンドで 多段 ssh

##### 背景
踏み台サーバーを経由してサーバにログインする場合、ユーザとパスワードを複数回入力しなければならない。
回避策として、ProxyCommand を利用する方法が便利で、推奨されている記事をよく見かけます。

```bash
ssh -o "ProxyCommand ssh -W %h:%p {ユーザid}@{踏み台サーバ}" -o "stricthostkeychecking=no" {接続先サーバ}
```

しかしながら、踏み台サーバで AllowTcpForwarding=yes になっている前提条件があり、no の場合、以下のようなエラーで失敗します。

```bash
channel 0: open failed: administratively prohibited: open failed
stdio forwarding failed
ssh_exchange_identification: Connection closed by remote host
```

そこで(あまり推奨されている記事を見かけないですが)、expect コマンドを使って、踏み台サーバ経由でログインする時のインタラクティブ処理を自動化します。

##### 期待する処理の流れ
1.ログインするサーバ環境の選択 (select environment)
特に仕事上では、Staging（開発用） や Production（本番用）で分けられていたりしますので、最初にどの環境のサーバにログインするのかを選択できるようにします。

2.ログインするサーバの選択(select_server_to_login)
前Step で選択した Staging or Production によって、ログインする候補のサーバ一覧を提示します。
予め以下のようなサーバの一覧が記載されたファイルを用意しておき、利用者側がその一覧から選択が出来るようにします。
それぞれのファイルは、本スクリプトと同ディレクトリに保管されていることを前提にしています。

```bash:stglist
stg-server1
stg-server2
stg-server3
```

```bash:prdlist
prd-server1
prd-server2
prd-server3
```

3.利用者側のユーザID(名)を入力 (input_user_name)
サーバログイン時に必要なユーザIDの入力を促します。

4.利用者側のパスワードを入力 (input_user_pass)
サーバログイン時に必要なパスワードの入力を促します。

5.サーバへログイン (login_to_server)
ここまでに入力された情報（ログイン先サーバ、ユーザ情報）を元に expect 内で ssh コマンドを用いて自動的にログイン（踏み台サーバ　→　目的のサーバ）させます。

##### expect コマンドの基本構文
```bash
expect -c "
spawn [実行したいコマンド]
expect [コマンドの返答]
send -- [次のコマンド]
"
```

##### スクリプト
以上の処理を考慮したスクリプトが以下になります。

```bash:server-login
#!/bin/bash

declare -r STG_STEP={your staging step server name}
declare -r PRO_STEP={your production step server name}
# 実行スクリプトのパス取得
declare -r SCRIPT_DIR=$(cd $(dirname $0); pwd)
declare -a servers=()
declare target_srv
declare user_name
declare user_pass
declare step_server

# readarray -t servers < file_name でファイルを行単位で読み込み配列作る
function read_srv_list() {
  readarray -t servers < $1
}

function select_server_to_login() {
  PS3='Please select server to login: '
  select target_srv in "${servers[@]}"
  do
    case ${target_srv} in
      "") echo "Invalid server $REPLAY";;
      *)
        echo "selected ${target_srv}"
        break
    esac
  done
}

function input_user_name() {
  echo -n "Input your user name: "
  read user_name
}

# read -s で入力内容を隠すことが出来ます
function input_user_pass() {
  echo -n "Input your user password: "
  read -s user_pass
}

# expectで条件分岐させるのは{}を使用することで可能
function login_to_server() {
  expect -c "
  set timeout 10
  spawn ssh ${user_name}@${step_server}
  expect \"Are you sure\" {
    send \"yes\n\"
    expect \"password\"
    send \"${user_pass}\n\"
  } \"password\" {
    send \"${user_pass}\n\"
  }

  expect \"~]\"
    send \"ssh ${target_srv}\n\"
  expect \"Are you sure\" {
    send \"yes\n\"
    expect \"password\n\"
    send \"${user_pass}\n\"
  } \"password\" {
    send \"${user_pass}\n\"
  }
  interact
  "
}

# PS3 prompt は select 文のためのもの
PS3='Please select environment: '
envs=("STG" "PRD" "EXIT")
select env_opt in "${envs[@]}"
do
  case ${env_opt} in
    "STG")
      echo "selected STG"
      step_server=${STG_STEP}
      read_srv_list ${SCRIPT_DIR}/stglist
      break
      ;;
    "PRD")
      echo "selected PRD"
      step_server=${PRO_STEP}
      read_srv_list ${SCRIPT_DIR}/prdlist
      break
      ;;
    "EXIT")
      exit 0
      ;;
    *) echo "Invalid environment $REPLAY";;
  esac
done

select_server_to_login
input_user_name
input_user_pass
login_to_server

exit 0
```

##### 実行サンプル
```
$ ./server-login
1) STG
2) PRD
3) EXIT
Please select environment: 2
selected STG

1) stg-server1
2) stg-server2
3) stg-server3
Please select server to login: 1
selected stg-server1

Input your user name: xxx
Input your user password: ***

# うまくいけばこれで目的のサーバまでログインできます
# ユーザやパスワードの入力を失敗した場合のリトライ機能はありません
```

##### さいごに
sshpass などを用いれば、パスワードの入力をスキップすることが可能なので、わざわざ expect コマンドを使う必要性は低いかもしれません。
開発環境に何らかの制約があって、それらの手段が取れない場合には検討できる手段になるかと思います。
また、複数台のサーバに同時ログインしたい場合、お使いのターミナルが複数窓に対して同時操作可能なブロードキャストに対応していれば、1回のユーザ名やパスワードの入力で、複数台に同時ログインすることも可能になるかと思います。