CiscoConfParseでNW機器コンフィグをパースする　

(余談) Advent Calendar 参加の記事なので、もうちょっと華やかな内容(?)でなにか書けないかなあと思ったんですがパッと思いつきませんでした…。

## はじめに

私はちょくちょく Batfish を触ってます。Batfish はいろいろできるんですが、そのへんはやらないのかぁ…と思うものもちょくちょくあります。わかりやすいところだと、現状 IPv6 には対応していないとか。そうしたものの 1 つに、L3 sub-interface の VLAN 情報を拾ってくれないというのがあります。どうも、スイッチで使われる switchport style なコンフィグじゃないと対応していないようです。(2021/12 月時点では)

たとえば Juniper MX でこういうコンフィグがあったとして、

```
interfaces {
    ...
    ae1 {
        flexible-vlan-tagging;
        ...
        unit 1010 {
            vlan-id 1010;
            family inet {
                address 172.31.10.1/24 {
                    vrrp-group 1 {
                        virtual-address 172.31.10.3;
                        priority 110;
                        accept-data;
                        track {
                            interface ae1 {
                                priority-cost 20;
                            }
                        }
                    }
                }
            }
        }
        unit 1110 {
            vlan-id 1110;
            family inet {
                address 172.31.110.1/24 {
                    vrrp-group 2 {
                        virtual-address 172.31.110.3;
                        priority 110;
                        accept-data;
                        track {
                            interface ae1 {
                                priority-cost 20;
                            }
                        }
                    }
                }
            }
        }
    }
    ...
}
```

Batfish で interface property table を表示してみるとこうなります。(interface_props.csv は batfish の `bfq.interfaceProperties` の出力を CSV に落としたもの)

```
$ cat <(head -n1 interface_props.csv) <(grep -i regionb-ce01 interface_props.csv | grep ae1) | tty-table

  ┌──┬──────────────┬───────┬────────┬────────┬───────────────┬───────┬──────────┬───────┬──────────┬───────────────────┬─────┐
  │  │  Interface   │ Acces │ Allowe │ Channe │ Channel_Group │ Descr │ Primary_ │ Switc │ Switchpo │ Switchport_Trunk_ │ VRF │
  │  │              │ s_VLA │ d_VLAN │ l_Grou │   _Members    │ iptio │ Address  │ hport │ rt_Mode  │   Encapsulation   │     │
  │  │              │   N   │   s    │   p    │               │   n   │          │       │          │                   │     │
  ├──┼──────────────┼───────┼────────┼────────┼───────────────┼───────┼──────────┼───────┼──────────┼───────────────────┼─────┤
  │4 │ regionb-ce01 │       │        │        │ ['ge-0/0/4',  │       │          │ False │   NONE   │       DOT1Q       │ def │
  │2 │    [ae1]     │       │        │        │  'ge-0/0/5']  │       │          │       │          │                   │ aul │
  │4 │              │       │        │        │               │       │          │       │          │                   │  t  │
  ├──┼──────────────┼───────┼────────┼────────┼───────────────┼───────┼──────────┼───────┼──────────┼───────────────────┼─────┤
  │4 │ regionb-ce01 │       │        │        │      []       │       │ 172.31.1 │ False │   NONE   │       DOT1Q       │ def │
  │2 │  [ae1.1010]  │       │        │        │               │       │  0.1/24  │       │          │                   │ aul │
  │5 │              │       │        │        │               │       │          │       │          │                   │  t  │
  ├──┼──────────────┼───────┼────────┼────────┼───────────────┼───────┼──────────┼───────┼──────────┼───────────────────┼─────┤
  │4 │ regionb-ce01 │       │        │        │      []       │       │ 172.31.1 │ False │   NONE   │       DOT1Q       │ VR1 │
  │2 │  [ae1.1110]  │       │        │        │               │       │ 10.1/24  │       │          │                   │     │
  │6 │              │       │        │        │               │       │          │       │          │                   │     │
  ├──┼──────────────┼───────┼────────┼────────┼───────────────┼───────┼──────────┼───────┼──────────┼───────────────────┼─────┤
  │4 │ regionb-ce01 │       │        │  ae1   │      []       │       │          │ False │   NONE   │       DOT1Q       │ def │
  │3 │  [ge-0/0/4]  │       │        │        │               │       │          │       │          │                   │ aul │
  │8 │              │       │        │        │               │       │          │       │          │                   │  t  │
  ├──┼──────────────┼───────┼────────┼────────┼───────────────┼───────┼──────────┼───────┼──────────┼───────────────────┼─────┤
  │4 │ regionb-ce01 │       │        │  ae1   │      []       │       │          │ False │   NONE   │       DOT1Q       │ def │
  │3 │  [ge-0/0/5]  │       │        │        │               │       │          │       │          │                   │ aul │
  │9 │              │       │        │        │               │       │          │       │          │                   │  t  │
  └──┴──────────────┴───────┴────────┴────────┴───────────────┴───────┴──────────┴───────┴──────────┴───────────────────┴─────┘
```

LAG (`Channel_Group`, ae = Aggregated Ethernet) の情報は取れていますが、VLAN や switchport の情報は取れていません。ためしに trunk encapsulation も出していますがコレもちょっと怪しいですね…。

このように、Batfish を単なる config parser, いろんなコンフィグから正規化されたデータを取るツールとして使っていて、ひとまず config を元にインタフェースのパラメータシートみたいなものを吐き出したいとすると、うーん別な方法を考えようかな……と思うのではないでしょうか。ここでは、そういう用途で候補に上がるであろう CiscoConfParse について紹介します。

## CiscoConfParse

- [mpenning/ciscoconfparse: Parse, Audit, Query, Build, and Modify Cisco IOS-style configurations.](https://github.com/mpenning/ciscoconfparse)
- [Welcome to ciscoconfparse’s documentation! — ciscoconfparse 1.6.10 documentation](http://www.pennington.net/py/ciscoconfparse/)

個人的に使ってみた感想になるんですが… NW機器コンフィグを parse しようとして "正規表現お化け" なスクリプトを作ってしまったことがある人は結構いるんじゃないでしょうか。その "お化け" 度合いを極力減らす・局所に抑えるために用意された wrapper という印象です。

名前の通り Cisco (IOS) 向けに最初は作ったんだと思いますが、Juniper (Junos) についてもいけます。Cisco-style だと、インデントでコンフィグブロックが指定されますよね。そうしたコンフィグブロック構造を意識して設定 (config文字列) にアクセスしていくための API が用意してあります。Juniper-style では `{}` で構造化されていますが、これを 1 つのコンフィグブロックとしてとらえる形になっています。

具体例はこの後みていきましょう。

## できたこと

試しに作ってみたスクリプトを [cconfparse-exp](https://github.com/corestate55/cconfparse-exp) においてあります。

先に挙げた例では ae1 の VLAN 情報が取れていませんでしたがこんな感じで埋めてみました。

```
$ python make_intf_prop_table.py -s junos -f RegionB-CE01_config.txt --csv > a.csv
# Parse config = RegionB-CE01_config.txt
# Hostname = RegionB-CE01
$ cat <(head -n1 a.csv) <(grep ae1 a.csv) | tty-table 

  ┌──┬──────────────┬───────┬────────┬────────┬───────────────┬───────┬──────────┬───────┬──────────┬───────────────────┬─────┐
  │  │  Interface   │ Acces │ Allowe │ Channe │ Channel_Group │ Descr │ Primary_ │ Switc │ Switchpo │ Switchport_Trunk_ │ VRF │
  │  │              │ s_VLA │ d_VLAN │ l_Grou │   _Members    │ iptio │ Address  │ hport │ rt_mode  │   Encapsulation   │     │
  │  │              │   N   │   s    │   p    │               │   n   │          │       │          │                   │     │
  ├──┼──────────────┼───────┼────────┼────────┼───────────────┼───────┼──────────┼───────┼──────────┼───────────────────┼─────┤
  │6 │ RegionB-CE01 │       │        │  ae1   │      []       │       │          │ False │   NONE   │       NONE        │ def │
  │  │  [ge-0/0/4]  │       │        │        │               │       │          │       │          │                   │ aul │
  │  │              │       │        │        │               │       │          │       │          │                   │  t  │
  ├──┼──────────────┼───────┼────────┼────────┼───────────────┼───────┼──────────┼───────┼──────────┼───────────────────┼─────┤
  │7 │ RegionB-CE01 │       │        │  ae1   │      []       │       │          │ False │   NONE   │       NONE        │ def │
  │  │  [ge-0/0/5]  │       │        │        │               │       │          │       │          │                   │ aul │
  │  │              │       │        │        │               │       │          │       │          │                   │  t  │
  ├──┼──────────────┼───────┼────────┼────────┼───────────────┼───────┼──────────┼───────┼──────────┼───────────────────┼─────┤
  │1 │ RegionB-CE01 │       │ 1010,1 │        │ ['ge-0/0/4',  │       │          │ True  │  TRUNK   │       DOT1Q       │ def │
  │2 │    [ae1]     │       │  110   │        │  'ge-0/0/5']  │       │          │       │          │                   │ aul │
  │  │              │       │        │        │               │       │          │       │          │                   │  t  │
  ├──┼──────────────┼───────┼────────┼────────┼───────────────┼───────┼──────────┼───────┼──────────┼───────────────────┼─────┤
  │1 │ RegionB-CE01 │       │        │        │      []       │       │ 172.31.1 │ False │   NONE   │       NONE        │ def │
  │3 │  [ae1.1010]  │       │        │        │               │       │  0.1/24  │       │          │                   │ aul │
  │  │              │       │        │        │               │       │          │       │          │                   │  t  │
  ├──┼──────────────┼───────┼────────┼────────┼───────────────┼───────┼──────────┼───────┼──────────┼───────────────────┼─────┤
  │1 │ RegionB-CE01 │       │        │        │      []       │       │ 172.31.1 │ False │   NONE   │       NONE        │ VR1 │
  │4 │  [ae1.1110]  │       │        │        │               │       │ 10.1/24  │       │          │                   │     │
  └──┴──────────────┴───────┴────────┴────────┴───────────────┴───────┴──────────┴───────┴──────────┴───────────────────┴─────┘
```

## コード例

ちょっとクラス分けとかをしているので中身が追いかけにくくなっているかもしれませんが……利用例としてわかりやすいのはこういうところでしょうか。ホスト名を取り出すところを見てみます。

```python
    def __init__(self, config: str, syntax: str = "ios") -> None:
        self.parser = CiscoConfParse(config=config, syntax=syntax)
        # 省略

    def hostname(self) -> str:
        # `hostname hoge` for ios, `host-name hoge;` for junos
        hostname_re = r"host-?name\s+([^\s;]+)"
        hostname_conf = self.parser.find_objects(hostname_re)[0]
        return hostname_conf.re_match_typed(hostname_re)
```

もうちょっと砕いて、step-by-step にみてみましょう。

```python
$ python -i
Python 3.8.3 (default, Jan  1 2021, 16:39:20) 
[GCC 9.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from ciscoconfparse import CiscoConfParse
>>> parser = CiscoConfParse(config="RegionB-CE01_config.txt", syntax="junos")
>>> hostname_re = r"host-?name\s+([^\s;]+)"
>>> parser.find_objects(hostname_re)
[<IOSCfgLine # 4 '    host-name RegionB-CE01' (parent is # 3)>]
>>> parser.find_objects(hostname_re)[0]
<IOSCfgLine # 4 '    host-name RegionB-CE01' (parent is # 3)>
>>> parser.find_objects(hostname_re)[0].re_match_typed(hostname_re)
'RegionB-CE01'
>>>
```

以下のステップでコンフィグを検索して必要な値を取り出しています。(各クラスやメソッドについては [API — ciscoconfparse](http://www.pennington.net/py/ciscoconfparse/api.html#) を参照してください)

- parser に Junos config を読ませています。
- `find_objects` で正規表現にマッチする行を検索しています。(これは親だけとかではなく全体を検索)
- 見つかると、`IOSCfgLine` クラスで wrap された "行" が出てきます
- `re_match_typed` メソッドで、見つかった行に対して正規表現マッチをかけて、マッチしたグループを値として取り出します

細かく個々の API 解説はしませんが、こんな感じで、トップレベルの行だけ探したり、親のコンフィグ (コンフィグブロック) を探して、その中 (子) のコンフィグを検索したり……等々の API が用意されています。

## 注意点

## CiscoConfParse について

Syntax check 問題 : [CiscoConfParse README](https://github.com/mpenning/ciscoconfparse) の図にあるとおりですが、あくまでも "parent line" + "children" の形で config block を捉えています。なので、厳密な文法チェックなどをしているわけではありません。ここでやっているように、実機から取得した設定ファイルを読むのであれば syntax として問題ないと思いますが、自分で書き起こしたコンフィグをチェックしたいといったケースでは、インデントの書き間違いやキーワードの typo などがあるとうまくいかないはずです。(これは正規表現ベースの parser ツール全般に当てはまります)

### NW機器の config parse 全般について

コンフィグの構造やキーワードを抽出できること : 今回、インタフェースのパラメータシート(表)を作ってみる形で試してみました。たとえば Channel Group, Channel Group Members 列なんかは、コンフィグ中で物理インタフェースの設定と LAG (Junos では AE) の設定がどうやって紐付けられているのかを知らないと組めません。VRF 列 (Junos では VR) なんかもそうですね。L3 のインタフェースがあった時に、VR のコンフィグを探して、どの VR への対応しているか調べる必要があります。

VR の設定は interface とは別なブロックとして存在しています

```
routing-instances {
    VR1 {
        instance-type virtual-router;
        interface ae1.1110;
        interface ae2.1120;
    }
}
```

VR 設定を探す → その子要素にいま注目している interface (unit) があれば、VR 名を返す (ここで `self._re["interface_typed"] = r"(\S+)"` なので読みにくい…。VR名自体がキーワードになっているので、それをとりだす。)

```python
    def _find_attached_vr(self) -> str:
        vr_conf_list = self._parser.find_objects(self._re["routing_instances"])
        if not vr_conf_list:
            return "default"
        for vr_conf in vr_conf_list[0].children:
            if vr_conf.re_search_children(self._re["routing_intf_func"](self._intf_unit_str())):
                return vr_conf.re_match_typed(self._re["interface_typed"])
        return "default"

    @property
    def vrf(self) -> str:
        # check ONLY layer3 interface
        if not self.primary_address:
            return "default"
        return self._find_attached_vr()
```

parser 使う人が、設定ファイルをちゃんと読みこなせること、やりたいことに応じて何の情報をどうやって取り出す・つなぎ合わせるかのロジックを組めることがポイントになります。Batfish だと触ったことのないデバイスのコンフィグのがあっても、Batfish 側が対応していれば正規化してデータとりだせたりしますが、その分細かいところには手が届きません。これはその「中身」に踏み込んで作るためのツールです。

したがって、構造が違うもの (IOS と Junos) は別に分けて考える必要がありますし、構造が似ているものならある程度同じロジックで使いまわせたりします。たとえば Cisco IOS と Arista EOS のコンフィグは同じロジックでパースできます。でも、微妙にキーワードやデータ形式が違うので (たとえば `ip address 192.168.0.3 255.255.255.0` なのか `ip address 192.168.0.3/24` なのかとか) 、そこが吸収できないといけない。まあこの手のツールでは毎度おなじみの話ではあるのですが。こういう微妙な違いを汎用的に吸収するのはとても面倒ですが、現状それはどうにかしていくしかない。最初から汎用化を考えると往々にして手が止まってしまうので、いま自分たちが使っているもの・これからやりたいことに対して足りるモノから始めていくのが良いのではないかと思います。

## その他の代替案

コンフィグファイルのパース・情報抽出みたいな観点だと、以下のようなツールも候補として上がるかもしれません。(私はちゃんと触ったことがないのであまり語れないんですが)

[dmulyalin/ttp: Template Text Parser](https://github.com/dmulyalin/ttp)

- 決められたフォーマットのテキストがあって、そこから狙ったフィールドの値を抽出するようなケースではこちらのほうが簡単かもしれません。
- 今回やったように、ある値から別な値の情報を検索・連結させて表示したい (上の channel group, VRF 情報のマッピングなど)…みたいな場合だとこれだけでは難しいかな…

[networktocode/network-importer](https://github.com/networktocode/network-importer)

- これはもっと上位レベルのツールですね。Batfish, Netbox などと連携して、既存のネットワークから情報をひっぱり出す・保存するツール。
- やりたいこと (構成管理) がマッチするならこっちのほうが楽かもしれません。

## おわりに

- CiscoConfParse で raw config のパースを楽にすることができる
- あくまでも raw config + regexp なので、見たい config, その種別ごとにパターンわけを考えていく必要がある
    - 正規表現からは逃れられない
    - コンフィグの構造を理解していないと何もできない

素で書くよりは楽にはなるけど one-size-fits-all なものにするのは難しいですねやっぱり。対象にするもの・やりたいことをしぼりこんで作っていくためのツールだと思います。

単純に、config にある情報を抽出してなにかに活用したいみたいなケースであれば、こういったもので自分のやりたいことに応じたパーサーがつくれます。Batfish みたいに、とりあえずコンテナ立ててコンフィグ食わせれば一式出てくる、というのだとどうしても足りないところが出ます。自分がやりたいことが既存のツールの枠内で足りればよいけど、足りてないときはこうした道具とかで埋めていくことも考えられるのではないでしょうか。