Pythonコードから多段SSHでホストに接続し、コマンドを実行してstdout出力を受け取る

# 記事の内容

下図（等幅フォントで見てね）のように、Pythonコードから踏み台サーバーを経由してVPSに多段SSH接続します。
そしてVPSで何らかのコマンドを実行し、stdout／stderrへの出力をPython側で受け取ります。

┌───────┐
│　　　　　　　│configで設定するホスト名：vps
│　　 VPS　　　│OS：CentOS 8.2
│　　　　　　　│sshポート：2222
│　　　　　　　│Ed25519鍵
└───────┘
　　　　↑
┌───┴───┐
│　　　　　　　│configで設定するホスト名：jump_host
│踏み台サーバー│OS：Ubuntu 20.04
│　　　　　　　│sshポート：22
│　　　　　　　│ECDSA鍵
└───────┘
　　　　↑
┌───┴───┐
│　　　　　　　│OS：WSL（Ubuntu 20.04）
│ Pythonコード │Python 3.8.5
│　　　　　　　│
└───────┘

# ssh環境

Python側のマシンで、ssh用に以下のファイルを用意します。

- 踏み台サーバーの秘密鍵： ~/.ssh/id_ecdsa
- VPSの秘密鍵： ~/.ssh/id_ed25519
- sshのconfigファイル： 以下の通り

```~/.ssh/config

Host jump_host
  Hostname XXX.XXX.XXX.XXX    # 書き換えてください
  User usrname    # 書き換えてください
  Port 22
  IdentityFile ~/.ssh/id_ecdsa

Host vps
  Hostname YYY.YYY.YYY.YYY    # 書き換えてください
  User usrname    # 書き換えてください
  Port 2222
  IdentityFile ~/.ssh/id_ed25519
  ProxyCommand sshpass -f ./.pass -P "Enter passphrase for key" ssh -o StrictHostKeyChecking=no -W %h:%p jump_host
```

vpsホストのProxyCommandは、「ProxyCommand ssh -W %h:%p jump_host」のように通常の書き方で書くと、Pythonコード実行時に踏み台サーバーのパスフレーズの手入力を求められます（パスフレーズが設定してあれば）。
今回はsshpass（=インストールしてください）を使用して、~/.ssh/.passファイル（=準備が必要です）からパスフレーズを読み込んで、踏み台サーバーのパスフレーズを自動入力させるように設定しています。

# Pythonコード

以下にPythonコードを記します。
コードが読み込む設定ファイル（.secret.ini）は後述します。

paramikoというライブラリを使用してSSH接続します。

コードで logger という変数を使っていますが、logging周りのコードは割愛しましたのでご了承ください。

```ssh_cmd.py

import configparser
from pathlib import Path
import datetime
import paramiko
from paramiko.config import SSH_PORT


current_directory = "."


def ssh_out(stderr, stdout, host, username, cmd):
    """
    ホストで実行したコマンドの出力結果をログに出力します
    """

    logger.info(f'''\
=============
[{username}@{host}]$ {cmd}
=============''')

    s = ""
    for e in stderr:
        s = s + e
    if 0 < len(s):
        logger.info(s)

    s = ""
    for o in stdout:
        s = s + o
    if 0 < len(s):
        logger.info(s)


def ssh_cmd(ssh_config, host, key_file, passphrase, sudo_password, list_cmd):
    """
    ホストにssh接続してコマンドを実行し、その出力結果をログに出力します
    """

    logger.info(f'''\
*************
host = {host}
*********''')

    # sshのconfigファイルから値を取り出す
    lookup = ssh_config.lookup(host)

    hostname = lookup["hostname"]
    username = lookup["user"]

    try:
        port = int(lookup["port"])
    except Exception:
        port = SSH_PORT

    key_filename = lookup['identityfile'],

        # 秘密鍵ファイルからキーを取得（パスフレーズを設定している場合）
    try:
        pkey = paramiko.Ed25519Key.from_private_key_file(key_file, passphrase)
    except Exception:
        try:
            pkey = paramiko.ECDSAKey.from_private_key_file(key_file, passphrase)
        except Exception:
            try:
                pkey = paramiko.RSAKey.from_private_key_file(key_file, passphrase)
            except Exception:
                pkey = None

        # 踏み台経由の場合
    try:
        sock = paramiko.ProxyJump(lookup["proxyjump"])
    except Exception:
        try:
            sock = paramiko.ProxyCommand(lookup["proxycommand"])
        except Exception:
            sock = None

    # ssh接続
    ssh_client = paramiko.SSHClient()
    try:
        ssh_client.load_system_host_keys()
        ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
        ssh_client.connect(
            hostname=hostname,
            port=port,
            username=username,
            key_filename=key_filename,
            pkey=pkey,
            sock=sock,
            )

        # ホスト側でコマンド実行
        for cmd in list_cmd:
            stdin, stdout, stderr = ssh_client.exec_command(cmd)
            if "sudo" in cmd:
                stdin.write(sudo_password + '\n')
                stdin.flush()
            ssh_out(stderr, stdout, host, username, cmd)
    finally:
        ssh_client.close()


def main():
    """
    メイン
    """

    # iniファイルの読み込み
    ini = configparser.ConfigParser()
    ini.read(f"{current_directory}/.secret.ini", encoding='utf-8')

        # ~/.ssh/config
    SECTION = "common"
    ssh_config_file = ini[SECTION]["ssh_config_file"]

        # 踏み台
    SECTION = "jump_host"
    jmp_key_file = ini[SECTION]["key_file"]
    jmp_passphrase = ini[SECTION]["passphrase"]
    jmp_sudo_password = ini[SECTION]["sudo_password"]

        # 踏み台経由で入るVPS
    SECTION = "vps"
    vps_key_file = ini[SECTION]["key_file"]
    vps_passphrase = ini[SECTION]["passphrase"]
    vps_sudo_password = ini[SECTION]["sudo_password"]


    # ~/.ssh/configの読み込み
    ssh_config = paramiko.SSHConfig()
    ssh_config.parse(open(ssh_config_file, 'r'))

    # まず、踏み台に入ってみる
    host = "jump_host"
        # ホストで実行させるコマンド
    list_cmd = [
        "cat /etc/issue",   # Ubuntuのバージョン表示
        "sudo -S ls -a",
    ]
    ssh_cmd(ssh_config, host, jmp_key_file, jmp_passphrase, jmp_sudo_password, list_cmd)

    # 踏み台経由でVPSに入ってみる
    host = "vps"
        # ホストで実行させるコマンド
    list_cmd = [
        "cat /etc/centos-release",   # CentOSのバージョン表示
        "sudo -S ls -a",
    ]
    ssh_cmd(ssh_config, host, vps_key_file, vps_passphrase, vps_sudo_password, list_cmd)


if __name__ == '__main__':
    main()
```

このPythonコードが読み込む設定ファイルを、以下の内容で準備する必要があります。
本記事では、ここに各ホストのパスフレーズとパスワードを書いてしまっていますので、注意が必要です。
運用時には、それぞれの状況に合わせて安全な方法をご検討ください。

```.secret.ini

[common]
ssh_config_file = /home/usrname/.ssh/config

[jump_host]
key_file = /home/usrname/.ssh/id_ecdsa
passphrase = secret
sudo_password = secret

[vps]
key_file = /home/usrname/.ssh/id_ed25519
passphrase = secret
sudo_password = secret
```

# 実行結果

Pythonコードを実行すると、jump_hostとvpsでそれぞれコマンドが動き、以下のように結果がログに出力されました（記事用に加工しています）。

```

*************
host = jump_host
*********

=============
[usrname@jump_host]$ cat /etc/issue
=============

Ubuntu 20.04.1 LTS \n \l



=============
[usrname@jump_host]$ sudo -S ls -a
=============

[sudo] usrname のパスワード: 

.
..
.bash_history
.bash_logout
.bashrc
.ssh
ダウンロード
テンプレート
デスクトップ
ドキュメント
ビデオ
ピクチャ
ミュージック
公開


*************
host = vps
*********

=============
[usrname@vps]$ cat /etc/centos-release
=============

CentOS Linux release 8.2.2004 (Core) 


=============
[usrname@vps]$ sudo -S ls -a
=============

[sudo] usrname のパスワード:

.
..
.bash_history
.bash_logout
.bash_profile
.bashrc
.ssh
.zshrc


Process finished with exit code 0
```

以上です。