Ansible Advent Calendar 2023の17日目の記事です。
概要
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"
こんな感じで書いてあげる必要があります
このモジュールを使い、TelnetやSSHコマンドを実行してNW機器だろうが何だろうが接続してやろうといった魂胆です
なぜTelnetモジュールを使わないのか?
- 機器側の出力が増えるととんでもなく遅くなる事象がありました。
キャプチャしたところTCP Zero Windowsが表示されまくり処理がいつまでたっても終わらなかったことがありました - login_promptやpassword_promptのパラメータにやや癖がありやめました
- 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で辞書作ろうとしてました)
--- - 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サーバからSSHやTelnetをすることができます
- 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でなんでもしようと思うと思わぬところではまったりします。
ベンダーモジュールを使えるように環境を整備する方に力をいれましょう。