Ansible(panosモジュール)×Jinja2でPaloaltoのログ採取を自動化してみた

# 1. 概要

幣チームでは、月に1～4個のPalo Altoを構築しており、作業後にコンフィグとコマンドに加えスクリーンショットでログ採取していた。しかし、ログはほとんど見返されておらず、取得にも時間がかかっていて、退屈でバリューの低い作業が数年続いていた。
そもそも論としてやめればよい話なのだが、自動化実践の良い機会と思い活用することにした。 
今回はPalo Alto機器へのコマンド実行・設定変更操作が可能なpanosモジュールとJinjaテンプレートを組み合わせ、これまでのログ採取作業を完全に自動化し、40~50分程度の作業を20秒以下に収めることができた。
![asis-tobe.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533083/ffaa0634-407f-0298-7b82-ca659ab6cfa5.png)

# 2. 想定対象読者
本記事の想定読者は次の通り。  
- ネットワーク自動化の事例を知りたい方  
- Python、Ansibleについてある程度知識がある方  

逆にAnsibleの基礎的な解説はないので気を付けてほしい。

# 3. 構成
![構成図1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533083/a6858030-d751-4171-7114-6a556c6a9dea.png)
CentOS端末、設定済みのPalo Altoが接続された状態。Ansible経由でPalo Altoへログインするにあたり、CentOSからPalo AltoへはSSH接続できる必要がある。  

# 4. Jinjaテンプレート  
Jinjaというテンプレート言語の説明をしておこう。Pythonのテンプレートエンジンであり、特にWebアプリケーションの開発においてよく使われる。

例えば、次のようにテンプレートを作成しておく
```template.j2
{{ name }} 様の連絡先は以下のとおりです。
メールアドレス：{{ email }}
電話番号      ：{{ phone_number }}
```
そこに次のように定義された変数を受け渡す。
```
name = "田中"
email = "example@mail.com"
phone_number ="xxx-xxx-xxx"
```
すると、値が穴埋めされた状態で出力される
```
田中 様の連絡先は以下のとおりです。
メールアドレス：example@mail.com
電話番号      ：xxx-xxx-xxx
```
テンプレート内でforやif文などPythonコードを埋め込むことができるため、動的なコンテンツを生成するのに役立つ。今回は上記のようにAnsibleで処理した結果をテンプレートに渡し、Markdown形式のレポートとして出力することを目指す。  

# 5. panosモジュール  
今回の肝となる`panos`モジュールについて、[公式](https://paloaltonetworks.github.io/pan-os-ansible/)から引用する。
>The Palo Alto Networks Ansible collection is a collection of modules that automate configuration and operational tasks on Palo Alto Networks Next Generation Firewalls (both physical and virtualized) and Panorama. The underlying protocol uses API calls that are wrapped within the Ansible framework.  

>Palo Alto Networks Ansible コレクションは、Palo Alto Networks Next Generation Firewalls (物理および仮想の両方) と Panorama の設定および運用タスクを自動化するモジュールのコレクションです。基礎となるプロトコルは、Ansible フレームワーク内でラップされた API 呼び出しを使用します。

と、Palo Alto向けに開発されたAnsibleのモジュールである。何ができるかというと、
 - [アドレスオブジェクトの追加](https://paloaltonetworks.github.io/pan-os-ansible/modules/panos_address_object_module.html)
 - [NATルールの追加](https://paloaltonetworks.github.io/pan-os-ansible/modules/panos_nat_rule2_module.html)
 - [設定変更後のコミット](https://paloaltonetworks.github.io/pan-os-ansible/modules/panos_commit_module.html)
など、ほかにも多種多様の操作が可能。
## 5.1. 動作バージョン  
- Python 3.8 or それ以上のバージョン  
- Ansible 2.9 or それ以上のバージョン  
  
CentOSだとPython2系がインストールされている場合もあるので確認しておこう。  
>筆者はPython2系、3系、Ansible2系、3系が混在した状態が原因で、panosモジュールでコマンド実行が正常に動作せず開始に時間を要した。  

## 5.2. インストール方法  
panosモジュールはansible-galaxyからインストールする。([ansible-galaxy](https://galaxy.ansible.com/paloaltonetworks/panos)) 
```
ansible-galaxy collection install paloaltonetworks.panos
```
panosモジュールを使うにはプレイブックのcollectionsに次のように書く必要がある。  
```  
collections:
    - paloaltonetworks.panos
```  
最小限の記述でpanosモジュールの利用を開始するPlaybookは次のようになる。  
```tmp.yml
---
    - hosts: all
      gather_facts: no
      connection: local
    
      # モジュールを呼び出す場合は書く
      collections:
        - paloaltonetworks.panos
      
      # セキュリティ上、インベントリファイルに書くのが望ましい
      vars:
        provider:                   
          ip_address: "192.168.1.1"
          username: "admin"
          password: "password"

      tasks:
        - name: show running config
          panos_op:
            provider: "{{ provider }}"
            cmd: "show config running "
          register: sh_run
```
# 6. Palo AltoのAPIの叩き方

今回はログ採取が目的なのでコマンド実行がメインとなる。  
panosモジュール（もしくはPAの）の仕様上、`show config running`のように素直に実行可能なコマンドもあれば、`<show><interface><all/></interface></show>`のXML形式でないと結果取得をできないコマンドも存在する。  
XML形式の場合、どのように調べればよいのだろうか？結論をいうと、Palo AltoのWebGUIの以下URLから調べることが可能だ。  
> https\://192.168.1.1/api/  

例えば、` show system info `の場合、Operational Commnad> show > system > infoとクリックを進めていくとXML形式でテキストボックスに表示される。これは後述するpanosモジュール内で使うので覚えておこう。  
![palo_api.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/533083/a31c19a0-28f9-2919-4b68-328e757abc8d.png)
> 筆者はpanosモジュールを使うまでこの機能を知らなかった。

# 7. コードの解説  
作成したコードは取得項目数の都合でコード量が多くなっている。可視性を考慮して、ここではすべては掲載せず最小限に抜粋して解説する。すべて見たい場合はGitHubに公開予定なのでそちらを参照してほしい。

>　準備中

## 7.1. ディレクトリ構成
```
ansible
  ┣main.yml             :実行用Playbook
  ┣inventory.ini        :実行対象ホスト情報定義ファイル
  ┗group_vars           :グループ変数を定義するディレクトリ
      ┗palo
          ┣palo.yml             :グループ変数定義
          ┣general.yml          :情報収集する対象を定義するファイル（コンフィグ）
          ┗system_info.yml      :情報収集する対象を定義するファイル（システム）
```
## 7.2. グループ変数定義用ファイル
```group_vars/palo/palo.yml
---
palo_provider:
  ip_address: '192.168.1.1'
  username: 'admin'
  password: 'password'
```
## 7.3. インベントリファイル
```inventory.ini
[palo]
PA-01 ansible_host=192.168.1.1

[palo:vars]
ansible_user="admin"
ansible_password="password"
```

## 7.4. テンプレートファイル  
```template.j2
# {{ general[0]["hostname"] | default("None")}}

## [ 一般情報 ]  

ホスト名              :{{ sys_info[0]["hostname"] | default("None") }}  
IP アドレス           :{{ sys_info[0]["ip_address"] | default("None")  }}  
ネットマスク          :{{ sys_info[0]["netmask"] | default("None")  }}  
デフォルトゲートウェイ :{{ sys_info[0]["default_gateway"] | default("None")  }}  
MAC アドレス          :{{ sys_info[0]["mac_address"] | default("None")  }}  
モデル                :{{ sys_info[0]["model"] | default("None") }}  
シリアル番号          :{{ sys_info[0]["serial"] | default("None") }}  
```

## 7.5. プレイブック
基本的には  
①panos_opモジュールでコマンドを実行。結果を変数に格納。  
②parse_xmlモジュールでほしい値をXML構造から抜き出す。
③Jinjaテンプレートに渡して出力  
の流れで進行する。
```main.yml
---
- hosts: palo
  gather_facts: no
  connection: local

  collections:
    - paloaltonetworks.panos
    - community.general

  tasks:
    ########## コマンド実行 ##########
    - name: show running config
      panos_op:
        provider: "{{ palo_provider }}"
        cmd: "show config running "
      register: sh_run

    - name: show system info
      panos_op:
        provider: "{{ palo_provider }}"
        cmd: "<show><system><info/></system></show>"
        cmd_is_xml: yes
      register: sh_system_info

    ########## 実行結果から必要な情報を抜き出す ##########
    
    - name: show config runninng XMLパース
      set_fact: parsed_sh_run="{{ sh_run.stdout_xml | parse_xml('group_vars/palo/general.yml') }}"

    - name: show system info XMLパース
      set_fact: parsed_system_info="{{ sh_system_info.stdout_xml | parse_xml('group_vars/palo/system_info.yml') }}"

    ##########  結果を出力 #######################

    - name: Markdown形式レポートに出力
      template:
        src: ./report_tmp.j2
        dest: ~/Desktop/report.md
      vars:
        general: "{{parsed_sh_run['result']}}"
        sys_info: "{{parsed_system_info['result']}}"
```
  
冒頭箇所では、インベントリファイルで`palo`と定義した対象機器グループに向かってタスク実行すると記述している。

```main.yml
---
- hosts: palo
  gather_facts: no
  connection: local

  collections:
    - paloaltonetworks.panos
    - community.general
```

次に、コマンドを実行していくフェーズ。`provider`の箇所はインベントリファイルからユーザ名、IPアドレス、パスワードを読み込んでAPIを叩く準備をしている。  

ちなみに`provider`と1行で書かずに直に書くこともできる。セキュリティの面から推奨しないと警告が出るが、これでも動作する。  
```tmp.yml
    - name: show config running
      panos_op:
        ip_address: "192.168.1.1"
        user: "admin"
        password: "password"
        cmd: "show config running "
      register: sh_run
```
   
次に、`cmd`に実行したいコマンドを記述する。  通常の形式で入力し実行して、エラーがでる場合は、XML形式で記述し、`cmd_xml`を`yes`に指定する必要がある。  最後に`register`で変数名を定義して結果を格納する。

```main.yml
 tasks:
    ########## コマンド実行 ##########
    - name: show config running
      panos_op:
        provider: "{{ palo_provider }}"
        cmd: "show config running "
      register: sh_run

    - name: show system info
      panos_op:
        provider: "{{ palo_provider }}"
        cmd: "<show><system><info/></system></show>"
        cmd_is_xml: yes
      register: sh_system_info
```
ここで、変数の中身を確認してみよう。確認方法は、  
`ansible-playbook (playbookのパス) -i (インベントリファイルのパス) -vvv`とコマンドの末尾に`-vvv`を付けるとデバッグモードになり確認できる。
```
# 結果
TASK [show config running]
**********************************************************************************************************************************************
            ((省略))
ok: [PA-01
] => {
    "changed": false,
    "disconnected": false,
    "invocation": {
        "module_args": {
            "api_key": null,
            "cmd": "show config running ",
            "cmd_is_xml": false,
            "ignore_disconnect": null,
            "ip_address": null,
            "password": null,
            "port": 443,
            "provider": {
                "api_key": null,
                "ip_address": "192.168.1.1",
                "password": "VALUE_SPECIFIED_IN_NO_LOG_PARAMETER",
                "port": 443,
                "serial_number": null,
                "username": "admin"
            },
            "username": "admin",
            "vsys": "vsys1"
        }
    },
    "msg": "Done",
    "stdout": "((省略))",
    "stdout_lines": [
        "((省略))"
    ],
    "stdout_xml": "
        <response status="success">
                <result>
                    <config version="9.1.0" urldb="paloaltonetworks">
                        <devices>
                            <entry name="localhost.localdomain">
                                <deviceconfig>
                                    <system>
                                        <ip-address>192.168.1.1</ip-address>
                                        <netmask>255.255.255.0</netmask>
                                        <hostname>test-palo</hostname>
                                        <default-gateway>192.168.1.254</default-gateway>
                                        <locale>ja</locale>
                                     </system>
                            </entry>
                        </devices>
                    </config>
               </result>
        </response> "
}
```
結果は辞書型になっている。値を取り出す場合は、Pythonで辞書から取り出すようにキーを指定していけばよい。`stdout_xml`の中身は、都合上かなり省略してしまっているが、XML形式で`show config running`の結果が表示されている。（本来は整形されていないので自力で整理する必要がある。）

## 7.6. 仕様ファイル
次に、`parse_xml`を使って実行結果を解析する。([公式ドキュメント](https://docs.ansible.com/ansible/2.9_ja/user_guide/playbooks_filters.html#:~:text=%E3%81%8C%E3%81%82%E3%82%8A%E3%81%BE%E3%81%99%E3%80%82-,%E3%83%8D%E3%83%83%E3%83%88%E3%83%AF%E3%83%BC%E3%82%AF%20XML%20%E3%83%95%E3%82%A3%E3%83%AB%E3%82%BF%E3%83%BC,-New%20in%20version))  
パースした結果は変数`parsed_sh_run`に格納する。

```main.yml
    ########## 実行結果から必要な情報を抜き出す ##########
    
    - name: show config runninng XMLパース
      set_fact: parsed_sh_run="{{ sh_run.stdout_xml | parse_xml('group_vars/palo/general.yml') }}"
```
ここで新たに、XMLから欲しい情報を解析するためのYAMLファイルを作成する。（公式では仕様ファイルと呼ばれている。）  

```group_vars/palo/general.yml
---
keys:
  result:
    value: "{{ dev_general_info }}"
    top: result/config/devices/entry/deviceconfig/system
    items:
      hostname: hostname
      ip_address: ip-address
      netmask: netmask
      default_gateway: default-gateway

vars:
  dev_general_info:
    hostname: "{{ item.hostname }}"
    ip_address: "{{ item.ip_address }}"
    netmask: "{{ item.netmask }}"
    default_gateway: "{{ item.default_gateway }}"
```
記述に際して気を付ける点は以下の通り。
 - `keys`配下の`result`箇所は任意の名前で構わない。
 - `top`のXPathは一番上の要素を含めない。（今回の場合は`<response>`が不要)  
 - `items`配下は`プレイブックやテンプレート内で扱う変数名` : `子要素`と記述する。
 - 変数名にはアンダースコア(_)は使えない
 - 取得したい構造を1つだけでなく複数書くこともできる 
<details><summary>複数書く場合（参考）</summary><div>

```
---
keys:
  result_1:
    value: "{{ dev_general_info }}"
    top: result/config/devices/entry/deviceconfig/system
    items:
      hostname: hostname
      ip_address: ip-address
      netmask: netmask
      default_gateway: default-gateway
  result_2: ##追加
    value: "{{ dev_general_info_2 }}"
    top: result/xxxx/xxxx/xxxx/xxxx/xxxx
    items:
      dummy: dummy

vars:
  dev_general_info_1:
    hostname: "{{ item.hostname }}"
    ip_address: "{{ item.ip_address }}"
    netmask: "{{ item.netmask }}"
    default_gateway: "{{ item.default_gateway }}"
  dev_general_info_2: ##追加
    dummy: "{{ item.dummy }}"
```  

</div></details>  


`-vvv`で見ると値を取り出せていることが分かる。（取り出せない場合は仕様ファイルの`top`のXPath指定が間違ってたり、変数にアンダースコアが入っている場合がある）これで値を取り出す準備はできた。  
```
# -vvvの結果
TASK [show config runninng XMLパース] 
*************************************************************************************************************************************************
task path: /etc/ansible/main.yml:67
ok: [PA-01] => {
    "ansible_facts": {
        "parsed_sh_run": {
            "result": [
                {
                    "default_gateway": "192.168.1.254",
                    "hostname": "test-palo",
                    "ip_address": "192.168.1.1",
                    "netmask": "255.255.255.0"
                }
       ]
    },
    "changed": false
}
```

## 7.7. 結果を出力  

最後にJinjaテンプレートに値を渡して出力する。
```main.yml
    ##########  結果を出力 #######################

    - name: Markdown形式レポートに出力
      template:
        src: ./report_tmp.j2
        dest: ~/Desktop/report.md
      vars:
        general: "{{parsed_sh_run['result']}}"
        sys_info: "{{parsed_system_info['result']}}"
```  
次のような出力レポートを得られる。
```report.md
# test-palo

## [ 一般情報 ]  

ホスト名              :test-palo  
IP アドレス           :192.168.1.1  
ネットマスク          :255.255.255.0  
デフォルトゲートウェイ :192.168.1.254  
```
# 8. 実際の出力結果
省略していないプレイブックで実行して出力されるレポートがこちら。  
これまでコマンド、スクリーンショットで時間をかけて取得していた内容が網羅されている。

```report.md
# test-palo

## [ 一般情報 ]  

ホスト名              :test-palo  
IP アドレス           :192.168.1.1  
ネットマスク          :255.255.255.0  
デフォルトゲートウェイ :192.168.1.254  
MAC アドレス          :xx:xx:xx:xx:xx:xx:xx  
モデル                :PA-220  
シリアル番号          :xxxxxxxxxxxxxx  

## [インターフェースリンクアップ状況]  

| インターフェース名 | id | speed/duplex/state | MACアドレス |  
| ---------------- | --- | ----------------- |------------ |  
|ethernet1/1 |16 |100/full/up |xx:xx:xx:xx:xx:xx |  
|ethernet1/2 |17 |1000/full/up |xx:xx:xx:xx:xx:xx |  
|ethernet1/3 |18 |1000/full/up |xx:xx:xx:xx:xx:xx |  
|vlan |1 |[n/a]/[n/a]/up |xx:xx:xx:xx:xx:xx |  
|loopback |3 |[n/a]/[n/a]/up |xx:xx:xx:xx:xx:xx |  
|tunnel |4 |[n/a]/[n/a]/up |xx:xx:xx:xx:xx:xx |  

## [高可用性]  

  
## [サービス]  

update-server                :updates.paloaltonetworks.com  
Primary DNS Server           :xx.xx.xx.xx  
Secondary DNS Server         :xx.xx.xx.xx  
Proxy Server                 :xx.xx.xx.xx  
Proxy Port                   :8080  
Primary Ntp サーバーアドレス   :  
Secondary Ntp サーバーアドレス :  

## [ セットアップ ]  

Peer IP        :  
Peer Ip Backup :  

## [ コントロールリンク＆リンクのバックアップ ]  

HA1 Backup Port       :  
HA1 Backup IPアドレス  :  
HA1 Backup ネットマスク :  

## [ データリンク(HA2)]  

HA2 Port :  

## [ダイナミック更新]  

| コンテンツ名 | バージョン | リリース日 |
| ----------- | --------- | --------- |
| Global protect |0.0.0 |  |  
| Applications | 8888-8888 | 2222/01/01 00:00:00 JST |  
| Antivirus | 8888-8888 | 2222/01/01 00:00:00 JST |  
| Threats | 8888-8888 | 2222/01/01 00:00:00 JST |  
| WildFire | 0 | unknown |  

## [ ライセンス ]  

[ Threat Prevention ]  
    発行日 :Jan 01, 2222  
    有効期限 :Jan 01, 2222  
    内容: Threat Prevention  
[ Premium Partner ]  
    発行日 :Jan 01, 2222  
    有効期限 :Jan 01, 2222  
    内容: Premium Partner  

## [ インターフェース情報 ]  

|インターフェース名 | マネジメントプロファイル | IPアドレス | Virtual Router | security Zone |  
| --------------- | ---------------------- | --------- | -------------- | --------------|  
| ethernet1/1 | mgmt |Address-Object1 |vr:default | trust |  
| ethernet1/2 | mgmt |Address-Object2 |vr:default | trust |  
| ethernet1/3 | mgmt |Address-Object3 |vr:default | untrust |  

## [ 仮想ルータ/Static Route ]  

| 名前 | 宛先 | インターフェース | タイプ | ネクストホップ | 管理距離 | メトリック | ルートテーブル |  
| ---- | --- | --------------- | ----- | ------------  | ------- | --------- | -------------|
| Default-route | 0.0.0.0/0  | ethernet1/1 |  | None | 10 | 10 |  

## [ アドレスグループ & メンバーオブジェクト ]  

| アドレスグループ | メンバーオブジェクト | アドレス | アドレスレンジ |  
| ------------------ | ------------------ | ------- |---------------|  
| Object-Group1 | Address-Object1 |xx.xx.xx.0/24| |  
| Object-Group1 | Address-Object2 |xx.xx.xx.0/24| |  
| | Address-Object3 | xx.xx.xx.1/24| |  
| | Address-Object4 | xx.xx.xx.2/24| |  

## [ セキュリティルール ]  

| 名前 | ゾーン | アドレス | ユーザー | HIPプロファイル | ゾーン | アドレス | アプリ | サービス | アクション | ヒット数 | 最後のヒット | 最初のヒット | 変更日 | 作成日 |  
| ---- | ----- | ------- | -------- | -------------- | ----- | ------- | ------ | ------- | --------- | ------- | ----------- | ----------- | ----- | ------ |  
| Default-rule | trust | any | any | any | trust | any | any | any | allow | 23765 | 2222-01-01-00-00 | 2222-01-01-00-00 | 2222-01-01-00-00 | 2222-01-01-00-00 |  

## [ ARPテーブル ]  

maximum of entries supported :      1500  
default timeout:                    1800 seconds  
total ARP entries in table :        5  
total ARP entries shown :           5  
status: s - static, c - complete, e - expiring, i - incomplete  
  
| インターフェース | IPアドレス | MACアドレス | ポート | ステータス | TTL |  
| -------------- | -----------| ---------  | ------| ---------- | ----|  
| ethernet1/1 | xx.xx.xx.xx | xx:xx:xx:xx:xx:xx:xx | ethernet1/1 |   c   | 1786 |  
| ethernet1/2 | xx.xx.xx.xx | xx:xx:xx:xx:xx:xx:xx | ethernet1/2 |   c   | 810 |  
| ethernet1/2 | xx.xx.xx.xx | xx:xx:xx:xx:xx:xx:xx | ethernet1/2 |   c   | 1791 |  

## [ NAT ]

```

# 9. 残課題
- 今のままでは変数`provider`の書き方の都合で1台ずつしか対応できないため複数端末があった場合の対処が必要
- 今回はPA220で試したが、他のシリーズで動作するか不明である。コンフィグ構造や要素名が変わるかもしれないので注意してほしい。

# 10. 最後に  

今回はpanosモジュールの中でも`panos_op`をメインに使用したが、全体のほんの一部にすぎない。ほかにも、アドレスオブジェクトやセキュリティルールの追加が可能であり、複数のPalo Altoに設定投入もできるので、構築者、運用者の視点で様々な機能を試してほしいと思う。

# 11. 参考資料  

 - Jason Edelman,Scott S. Lowe,Matt Oswalt(2022).ネットワーク自動化とプログラマビリティ ―次世代ネットワークエンジニアのためのスキルセット. オライリージャパン.