🍳

SSHアクセスできないNW装置だってAnsibleコアモジュールで操作したい!

2021/11/15に公開

はじめに

複数のサーバやクラウドの構成管理に使われるAnsibleは、バージョン2.1からNetwork Automationがコアモジュールとして正式サポートされた。Cisco IOS、Cisco IOS-XR、Cisco NXOSなどの各種NW装置についてもそれぞれAnsibleモジュール提供され、容易に操作できるようになっている。

ただしこれらAnsibleモジュールでは対象NW装置への接続にセキュアなsshプロトコルを利用しており、telnetプロトコルはサポートされていない。このため、telnetサービスは有効であるがsshサービスが有効になっていないような装置では、これらAnsibleモジュールが提供する高度な自動化が実現できない。

このような場合、まず考えるべきは対象NW装置でsshサービスを有効にすること。そもそもtelnetサービスしか有効になっていないことが現代の運用ではあり得ないのであって、セキュリティ対策上でもこれ以外の対応を考えるべきではない。ただしろくでもない事情によって、そうもいかないケースももしかしたら稀にあるかも知れない。あるのかな?[1]

さて以下では、何らかの事情によってtelnetサービスしか使えないNW装置で、いかにAnsibleを使うかを検討してみた。

まずは比較対象として、Cisco IOS-XR装置に対してcisco.iosxr.iosxr_commandモジュールをつかったssh接続を行う自動化手順を紹介する。インベントリファイルinventory.ymlを用意する。定義した装置はrouter01router02の2台。[2]

iosxr_routers:
    vars:
        ansible_user: 'cisco'
        ansible_password: 'cisco'
        ansible_connection: 'network_cli'
        ansible_network_os: 'iosxr'
    hosts:
        router01:
            ansible_host: 10.1.0.1
        router02:
            ansible_host: 10.1.0.2

次にプレイブックplaybook.ymlを用意する。インベントリで定義した装置について、show ipv4 interface briefコマンドの出力結果を表示するだけの単純なもの。

- hosts: iosxr_routers
  gather_facts: false

  tasks:
      - name: send command
        cisco.iosxr.iosxr_command:
                commands:
                    - 'show ipv4 interface brief'
        register: command_result

      - name: display result
        debug:
                var: command_result

では実行してみる。

$ ansible-playbook -i inventory.yml playbook.yaml

PLAY [iosxr_routers] *************************************************************************************************************************

TASK [send command] *******************************************************************************************************************
ok: [router01]
ok: [router02]

TASK [display result] *****************************************************************************************************************
ok: [router02] => {
    "command_result": {
        "changed": false,
        "failed": false,
        "stdout": [
            "Interface                      IP-Address      Status          Protocol Vrf-Name\nLoopback0                      10.1.0.2        Up              Up       default \nMgmtEth0/0/CPU0/0              unassigned      Shutdown        Down     default \nGigabitEthernet0/0/0/0         172.16.0.14     Up              Up       default \nGigabitEthernet0/0/0/1         172.16.0.18     Up              Up       default \n"
        ],
        "stdout_lines": [
            [
                "Interface                      IP-Address      Status          Protocol Vrf-Name",
                "Loopback0                      10.1.0.2        Up              Up       default ",
                "MgmtEth0/0/CPU0/0              unassigned      Shutdown        Down     default ",
                "GigabitEthernet0/0/0/0         172.16.0.14     Up              Up       default ",
                "GigabitEthernet0/0/0/1         172.16.0.18     Up              Up       default "
            ]
        ]
    }
}
ok: [router01] => {
    "command_result": {
        "changed": false,
        "failed": false,
        "stdout": [
            "Interface                      IP-Address      Status          Protocol Vrf-Name\nLoopback0                      10.1.0.1        Up              Up       default \nMgmtEth0/0/CPU0/0              unassigned      Shutdown        Down     default \nGigabitEthernet0/0/0/0         172.16.0.2      Up              Up       default \nGigabitEthernet0/0/0/1         172.16.0.6      Up              Up       default \n"
        ],
        "stdout_lines": [
            [
                "Interface                      IP-Address      Status          Protocol Vrf-Name",
                "Loopback0                      10.1.0.1        Up              Up       default ",
                "MgmtEth0/0/CPU0/0              unassigned      Shutdown        Down     default ",
                "GigabitEthernet0/0/0/0         172.16.0.2      Up              Up       default ",
                "GigabitEthernet0/0/0/1         172.16.0.6      Up              Up       default "
            ]
        ]
    }
}

PLAY RECAP *****************************************************************************************************************************
router01            : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
router02            : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

はい、できた。
それぞれ2台の装置に対してshow ipv4 interface briefコマンドの結果が無事に表示された。これと同じ出力を、telnetプロトコルによる接続のみで得たい。

ansible.netcommon.telnetモジュールを利用する

ではansible.netcommon.telnetモジュールを使う方法を紹介する。
sshサービスが有効でない場合、通常telnetモジュールを使う以外に検討余地はないだろう。

インベントリファイルinventory.ymlは特に書き換える必要がない。
プレイブックplaybook.ymlは以下のように書き換える。

- hosts: iosxr_routers
  gather_facts: false

  tasks:
      - name: send command
        ansible.netcommon.telnet:
            user:     "{{ ansible_user }}"
            password: "{{ ansible_password }}"
            login_prompt:    "Username: "
            password_prompt: "Password: "
            prompts:
                - "[>#]"
            command:
                - 'show ipv4 interface brief'
        register: command_result
        changed_when: False

      - name: display result
        debug:
                var: command_result

telnetモジュールは要はexpectコマンドのようなもので、コマンドを入力しその出力(次のプロンプトまでの文字列)を抜き出すというもの。そのためログイン時に画面に表示されるログインプロンプト文字列login_promptとパスワードプロンプト文字列password_promptをプレイブック中に指定し、ログイン処理に備える必要がある。コマンドプロンプト文字列promptsを指定しているのも同様の理由によるもの。

実行してみる。

$ ansible-playbook -i inventory.yml playbook.yml

PLAY [iosxr_routers] *************************************************************************************************************************

TASK [send command] *******************************************************************************************************************
ok: [router02]
ok: [router01]

TASK [display result] *****************************************************************************************************************
ok: [router02] => {
    "iosxr_results": {
        "changed": false,
        "failed": false,
        "output": [
            "show ipv4 interface brief\r\n\rMon Nov 15 21:01:11.749 JST\r\n\r\nInterface                      IP-Address      Status          Protocol Vrf-Name\r\nLoopback0
      10.1.0.2        Up              Up       default \r\nMgmtEth0/0/CPU0/0              unassigned      Shutdown        Down     default \r\nGigabitEthernet0/0/0/0         172.16.0.14     Up              Up       default \r\nGigabitEthernet0/0/0/1         172.16.0.18     Up              Up       default \r\n\r\n\rRP/0/0/CPU0:router02#"
        ]
    }
}
ok: [router01] => {
    "iosxr_results": {
        "changed": false,
        "failed": false,
        "output": [
            "show ipv4 interface brief\r\n\rMon Nov 15 21:01:11.288 JST\r\n\r\nInterface                      IP-Address      Status          Protocol Vrf-Name\r\nLoopback0
      10.1.0.1        Up              Up       default \r\nMgmtEth0/0/CPU0/0              unassigned      Shutdown        Down     default \r\nGigabitEthernet0/0/0/0         172.16.0.2      Up              Up       default \r\nGigabitEthernet0/0/0/1         172.16.0.6      Up              Up       default \r\n\r\n\rRP/0/0/CPU0:router01"
        ]
    }
}

PLAY RECAP *****************************************************************************************************************************
router01            : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
router02            : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

できた。
一見特に大きな問題なく操作できたように見える。実際この程度のコマンド結果を表示する程度のプログラムであれば、これで事足りる。

ではここで、対象NW装置に対してコンフィグモードからインタフェースにIPアドレスを設定したい場合にも、telnetモジュールは同様に使えるだろうか。出力をきれいに整形して別プログラムで再利用する必要がある場合はどうだろうか。プロンプト文字列が通常と異なるようなコマンドの場合にも対応可能だろうか。
telnetモジュールではあくまで基本的な操作しか実現できず、iosxr_commandiosxr_configなどのコアモジュールにより提供される機能と同様の高度な操作を実現しようとすると、複雑な逐次手順を記述しなくてはならずAnsibleによる自動化の便利さ・容易さを享受できないことが多い。

sshをtelnetに変換するプロキシを利用する

やはりここは(telnetモジュールのような代替手段でなく)iosxr_commandiosxr_configなどのAnsibleコアモジュールを使いたい。

さて、ここにsshプロトコルをtelnetプロトコルに変換できる自作ツールがある。これを使って元のプレイブックをそのまま実行してみることを考える。
https://github.com/haccht/ssh2telnet

まずはツールのバイナリをリリースからダウンロードして、パスの通ったディレクトリに保存し、以下の通り起動する。

$ ssh2telnet --addr :2222 --login --login-prompt 'Username: ' --password-prompt 'Password: '
Starting ssh server on :2222

--login-promptおよび--password-promptではIOS-XR装置のログインプロンプトとパスワードプロンプトを指定する。これらはssh2telnetプログラムが対象装置にtelnet接続し自動ログインする際に利用される。
ツールを起動すると、2222/tcpポートでsshサーバーが待ち受けを開始する。

それではこのツール宛てのssh接続がきちんと対象装置宛てのtelnet接続に変換され、自動ログインできることを確認してみる。

別ターミナルから以下のコマンドを入力する。-l cisco@10.1.0.1オプションではtelnetで接続する対象について、<ログインユーザ名>@<装置アドレス>の形式で指定している。sshコマンド実行直後にパスワード入力を求められるので、ログインパスワード(ここではcisco)を入力すること。

$ ssh localhost -p 2222 -l cisco@10.1.0.1
cisco@10.1.0.1@localhost's password:


RP/0/RSP0/CPU0:router01#show clock
Thu Nov 15 13:00:00.000 JST
13:00:00:000 JST Thu Nov 15 2021

RP/0/RSP0/CPU0:router01#exit
Connection to localhost closed.

できた!sshサービスが有効になっていない装置に、sshプロトコルを使ってtelnet接続できた。ほかにも自由にコマンドを入力し、問題なく動作することを試してみよう。

ではこのツールを使う前提で、インベントリファイルinventory.ymlを書き換えてみる。

iosxr_routers:
    vars:
        ansible_host: 'localhost'
	ansible_port: '2222'
        ansible_password: 'cisco'
        ansible_connection: 'network_cli'
        ansible_network_os: 'iosxr'
    hosts:
        router01:
            ansible_user: 'cisco@10.1.0.1'
        router02:
	    ansible_user: 'cisco@10.1.0.2'

ansible_host, ansible_portansible_userのパラメータが変わっているので注意。
プレイブックplaybook.ymlのほうは変更が必要ない。以下再掲。

- hosts: iosxr_routers
  gather_facts: false

  tasks:
      - name: send command
        cisco.iosxr.iosxr_command:
                commands:
                    - 'show ipv4 interface brief'
        register: command_result

      - name: display result
        debug:
                var: command_result

では実行してみる。
なおssh2telnetはsshサーバーのHOST_KEYを起動のたび自動生成するため、停止・再起動後のssh接続ではfingerprintがマッチしない旨の警告が表示されることがある。これを防ぐため、プレイブック実行前にANSIBLE_HOST_KEY_CHECKING環境変数をfalseに設定している。[3]

$ export ANSIBLE_HOST_KEY_CHECKING=false
$ ansible-playbook -i inventory.yml playbook.yaml

PLAY [iosxr_routers] *************************************************************************************************************************

TASK [send command] *******************************************************************************************************************
ok: [router01]
ok: [router02]

TASK [display result] *****************************************************************************************************************
ok: [router02] => {
    "command_result": {
        "changed": false,
        "failed": false,
        "stdout": [
            "Interface                      IP-Address      Status          Protocol Vrf-Name\nLoopback0                      10.1.0.2        Up              Up       default \nMgmtEth0/0/CPU0/0              unassigned      Shutdown        Down     default \nGigabitEthernet0/0/0/0         172.16.0.14     Up              Up       default \nGigabitEthernet0/0/0/1         172.16.0.18     Up              Up       default \n"
        ],
        "stdout_lines": [
            [
                "Interface                      IP-Address      Status          Protocol Vrf-Name",
                "Loopback0                      10.1.0.2        Up              Up       default ",
                "MgmtEth0/0/CPU0/0              unassigned      Shutdown        Down     default ",
                "GigabitEthernet0/0/0/0         172.16.0.14     Up              Up       default ",
                "GigabitEthernet0/0/0/1         172.16.0.18     Up              Up       default "
            ]
        ]
    }
}
ok: [router01] => {
    "command_result": {
        "changed": false,
        "failed": false,
        "stdout": [
            "Interface                      IP-Address      Status          Protocol Vrf-Name\nLoopback0                      10.1.0.1        Up              Up       default \nMgmtEth0/0/CPU0/0              unassigned      Shutdown        Down     default \nGigabitEthernet0/0/0/0         172.16.0.2      Up              Up       default \nGigabitEthernet0/0/0/1         172.16.0.6      Up              Up       default \n"
        ],
        "stdout_lines": [
            [
                "Interface                      IP-Address      Status          Protocol Vrf-Name",
                "Loopback0                      10.1.0.1        Up              Up       default ",
                "MgmtEth0/0/CPU0/0              unassigned      Shutdown        Down     default ",
                "GigabitEthernet0/0/0/0         172.16.0.2      Up              Up       default ",
                "GigabitEthernet0/0/0/1         172.16.0.6      Up              Up       default "
            ]
        ]
    }
}

PLAY RECAP *****************************************************************************************************************************
router01            : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
router02            : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

プレイブックは一切修正することなく、まったく同一の出力を得ることができた。
やったね🎉[4]

脚注
  1. あまり探りを入れないほうがいい。Don't think, feel. ↩︎

  2. こういった検証目的の装置はIOSXRvをvagrantとVirtualBoxで一発起動する方法やIOSXRvをvagrant-libvirtで一発起動する方法で用意するのがたいへん便利 ↩︎

  3. ssh2telnet -k /path/to/hostkeyで静的にHOST_KEYを指定することも可能 ↩︎

  4. ssh2telnetを使って大規模なプレイブックを実行した実績が、実は私にはまだない。何か不具合や問題があれば、GithubにIssue報告もらえるとうれしい ↩︎

Discussion