うさラボ

お勉強と備忘録

[Ansible] expectモジュールでNW機器へのアクセスを試みる

Ansible Advent Calendar 2023の17日目の記事です。

qiita.com

概要

Ansibleを使いたいけどTelnetしか対応していないんだよね~
踏み台アクセスってできる?

といったAnsible使う上では中々の無茶ぶりを要求されることがあります(あります)

Ansibleは基本的にはSSHが前提となっているため、難しいことをしっかりと伝えましょう。しかし、どうしてもやらないといけない場合もあります。そんな時どうするのか?僕はexpectモジュールを使いました。

紹介します

expectモジュール

コマンドを実行して、表示されたプロンプトに対応する応答を返すモジュールになります。利用にはpexpectライブラリが必要になるので事前にインストールしておきましょう

pip install pexpect

モジュールの開始時に実行するコマンドのcommandパラメータと、そのコマンドに対するプロンプトと応答のresponsesパラメータを定義します。
下記の場合、/path/to/custom/commandを実行後Questionのプロンプトが表示されるので、表示されるごとにresponse1,response2,respose3の順番で応答します。 responsesのキーがプロンプト、バリューが応答の関係になります。応答はリストで定義すると上から順番に実行していきリストの要素をすべて応答しきってもプロンプトが表示される場合とエラーになります。文字列で定義するとプロンプトに対して何度でも応答ができます。

- name: Generic question with multiple different responses
  ansible.builtin.expect:
    command: /path/to/custom/command
    responses:
      Question:
        - response1
        - response2
        - response3

NW機器の場合はログイン時に表示されるホスト名>ホスト名#をキーにします。踏み台アクセスしたいよって場合、踏み台用のプロンプトを作ればいいってことです。

リダイレクトやパイプを使いたい場合は、ドキュメントにあるように/bin/bash -c "/path/to/something | grep else"こんな感じで書いてあげる必要があります

このモジュールを使い、TelnetSSHコマンドを実行してNW機器だろうが何だろうが接続してやろうといった魂胆です

docs.ansible.com

なぜTelnetモジュールを使わないのか?

  1. 機器側の出力が増えるととんでもなく遅くなる事象がありました。
    キャプチャしたところTCP Zero Windowsが表示されまくり処理がいつまでたっても終わらなかったことがありました
  2. login_promptやpassword_promptのパラメータにやや癖がありやめました
  3. promptsとcommandがexpectのほうが柔軟でした

準備

さてNW機器に接続するぜと息巻いたもののNW機器を準備できませんでした。
なのでNW機器のプロンプトを再現する用のスクリプトを動かしてそれっぽく出てくる応答を取得できるか確認することにします。

responses ={"show_ip_route" : """Codes: C - connected, S - static, R - RIP, M - mobile, B - BGP
       D - EIGRP, EX - EIGRP external, O - OSPF, IA - OSPF inter area
       N1 - OSPF NSSA external type 1, N2 - OSPF NSSA external type 2
       E1 - OSPF external type 1, E2 - OSPF external type 2
       i - IS-IS, su - IS-IS summary, L1 - IS-IS level-1, L2 - IS-IS level-2
       ia - IS-IS inter area, * - candidate default, U - per-user static route
       o - ODR, P - periodic downloaded static route, H - NHRP, l - LISP
       a - application route
       + - replicated route, % - next hop override

Gateway of last resort is not set

      10.0.0.0/8 is variably subnetted, 2 subnets, 2 masks
C        10.1.1.0/24 is directly connected, GigabitEthernet0/0
L        10.1.1.1/32 is directly connected, GigabitEthernet0/0

      172.16.0.0/16 is variably subnetted, 3 subnets, 2 masks
C        172.16.1.0/24 is directly connected, GigabitEthernet0/1
L        172.16.1.1/32 is directly connected, GigabitEthernet0/1
S        172.16.2.0/24 [1/0] via 10.1.1.2"""}

import re
if __name__ == '__main__':
    hostname = 'hogehoge'
    while True:
        user_prompt = input(hostname + '> ')
        if user_prompt == 'exit':
            break
        elif user_prompt == 'enable':
            while True:
                admin_prompt = input(hostname + '# ')
                if admin_prompt == 'exit':
                    break
                elif re.match(r'^show', admin_prompt):
                    response_key = admin_prompt.replace(' ','_')
                    if response_key in responses.keys():
                        print(responses[response_key])
                    else:
                        print('すまん、未対応や')
                elif admin_prompt == 'help':
                    [print(i.replace('_',' ')) for i in responses.keys()]
        else:
            print('すまん、未対応や')

このスクリプトで,NW機器(Cisco)プロンプト待ちを再現しています
おまけでshow ip routeを打つとそれっぽい応答を返すようにしました

(2023_Advend) [usaen@eda work]$ python rt_prompt.py 
hogehoge> enable
hogehoge# show ip route
Codes: C - connected, S - static, R - RIP, M - mobile, B - BGP
       D - EIGRP, EX - EIGRP external, O - OSPF, IA - OSPF inter area
       N1 - OSPF NSSA external type 1, N2 - OSPF NSSA external type 2
       E1 - OSPF external type 1, E2 - OSPF external type 2
       i - IS-IS, su - IS-IS summary, L1 - IS-IS level-1, L2 - IS-IS level-2
       ia - IS-IS inter area, * - candidate default, U - per-user static route
       o - ODR, P - periodic downloaded static route, H - NHRP, l - LISP
       a - application route
       + - replicated route, % - next hop override

Gateway of last resort is not set

      10.0.0.0/8 is variably subnetted, 2 subnets, 2 masks
C        10.1.1.0/24 is directly connected, GigabitEthernet0/0
L        10.1.1.1/32 is directly connected, GigabitEthernet0/0

      172.16.0.0/16 is variably subnetted, 3 subnets, 2 masks
C        172.16.1.0/24 is directly connected, GigabitEthernet0/1
L        172.16.1.1/32 is directly connected, GigabitEthernet0/1
S        172.16.2.0/24 [1/0] via 10.1.1.2
hogehoge# exit
hogehoge> exit

Playbook

実際に動かすPlaybookを書きます
set_factで生成した辞書をexpectモジュールに渡そうと思いましたが、>や#で終わる場合はエラーになるようでうまく設定できませんでした そのため、community.generalコレクションのdictフィルタを利用しました (実はこのフィルタ、この記事を書き始めてから知りました、頑張ってloopとcombineで辞書作ろうとしてました)

docs.ansible.com

---
- name: 2023 Advent Carender
  hosts: localhost
  gather_facts: false
  tasks:
    - name: これはうまくいかない
      ansible.builtin.set_fact:
        "{{ hostname }}>": ["enable", "exit"]
        "{{ hostname }}#": ["ter len 0", "exit"]
      vars:
        hostname: hogehoge
      ignore_errors: true

    - name: Expectで使う用の変数
      ansible.builtin.set_fact:
        prompt2: "{{ prompt_keys | zip(prompt_values) | community.general.dict }}"
      vars:
        prompt_keys:
          [
            "{{ hostname }}>", 
            "{{ hostname }}#",
          ]
        prompt_values:
          [
              ["enable", "exit"], 
              ["ter len 0", "show ip route", "exit"],
          ]
        hostname: hogehoge

    - name: expect
      ansible.builtin.expect:
        command: python rt_prompt.py
        responses: "{{ prompt2 }}"
        echo: true
      register: res_expect

    - name: 実行結果
      debug:
        msg: "{{ res_expect.stdout }}"

expectモジュールで設定しているechoのパラメータは、応答した内容を結果に表示するか?の設定で、上記例だと、enableやshow ip routeなど応答した情報も結果に表示させれます。

telnet、もしくはsshコマンドをcommandで設定することでAnsibleサーバからSSHTelnetをすることができます

  - name: expect
      ansible.builtin.expect:
        command: telnet {{ ansible_host }}
        responses: "{{ prompt2 }}"
        echo: true
      register: res_expect

この場合は、当然responsesにはUserやPasswordなど、Telnet/SSHコマンド実行時のプロンプトも追加する必要があります。

実行

stdoutを表示するので、デフォルトのコールバックプラグインでは見づらいため
ANSIBLE_STDOUT_CALLBACKをyamlに変更しつつ実行します

$ ANSIBLE_STDOUT_CALLBACK=yaml ansible-playbook expect_sample.yml -i hosts.yml 

PLAY [2023 Advent Carender] **************************************************************

TASK [これはうまくいかない] **************************************************************
An exception occurred during task execution. To see the full traceback, use -vvv. The error was: NoneType: None
fatal: [localhost]: FAILED! => changed=false 
  msg: The variable name 'hogehoge>' is not valid. Variables must start with a letter or underscore character, and contain only letters, numbers and underscores.
...ignoring

TASK [Expectで使う用の変数] **************************************************************
ok: [localhost]

TASK [expect] ****************************************************************************
changed: [localhost]

TASK [実行結果] **************************************************************************
ok: [localhost] => 
  msg: |-
    hogehoge> enable
    hogehoge# ter len 0
    hogehoge# show ip route
    Codes: C - connected, S - static, R - RIP, M - mobile, B - BGP
           D - EIGRP, EX - EIGRP external, O - OSPF, IA - OSPF inter area
           N1 - OSPF NSSA external type 1, N2 - OSPF NSSA external type 2
           E1 - OSPF external type 1, E2 - OSPF external type 2
           i - IS-IS, su - IS-IS summary, L1 - IS-IS level-1, L2 - IS-IS level-2
           ia - IS-IS inter area, * - candidate default, U - per-user static route
           o - ODR, P - periodic downloaded static route, H - NHRP, l - LISP
           a - application route
           + - replicated route, % - next hop override
  
    Gateway of last resort is not set
  
          10.0.0.0/8 is variably subnetted, 2 subnets, 2 masks
    C        10.1.1.0/24 is directly connected, GigabitEthernet0/0
    L        10.1.1.1/32 is directly connected, GigabitEthernet0/0
  
          172.16.0.0/16 is variably subnetted, 3 subnets, 2 masks
    C        172.16.1.0/24 is directly connected, GigabitEthernet0/1
    L        172.16.1.1/32 is directly connected, GigabitEthernet0/1
    S        172.16.2.0/24 [1/0] via 10.1.1.2
    hogehoge# exit
    hogehoge> exit

PLAY RECAP *******************************************************************************
localhost                  : ok=4    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=1  

プロンプトと応答をコツコツ定義していくのでTeratermマクロみたいっちゃみたいです。

ちなみ、NW機器はバージョンやOSによって改行コードが違ったり、プロンプトの表示が微妙に変わったりするので、expectでなんでもしようと思うと思わぬところではまったりします。

ベンダーモジュールを使えるように環境を整備する方に力をいれましょう。