Netmikoのソースを見ながら使い方を調べる

今更ですが昔より Python の理解が深まったため[Netmiko](https://github.com/ktbyers/netmiko)について改めて調べたことをまとめます。

今回作成したソースは[sourjp/lab-network](https://github.com/sourjp/lab-network)においてます。

# Netmiko?

> Multi-vendor library to simplify Paramiko SSH connections to network devices

記載の通り、Paramiko の SSH 接続を行う機能を利用しつつ、コマンド操作をベンダに合わせたメソッドが実装されたライブラリです。またマルチベンダーと言うように[パッケージ](https://github.com/ktbyers/netmiko/tree/develop/netmiko)をみる限り juniper, cisco, vyos, f5, extreme, yamaha, huawei など有名どころの実装は用意されています。

# 基本的な使い方

仕組みは簡単です。

1. dict で接続情報を定義
2. netmiko(paramiko) でコネクションを作成
3. コマンドを投げる

主に使用するメソッドは次の通りです。

| メソッド                | 使い道                                             |
| ----------------------- | -------------------------------------------------- |
| send_command()          | show コマンドなど情報の取得                        |
| send_config_set()       | 設定コマンドを投入                                 |
| send_config_from_file() | 設定コマンドをファイルから読み込んでコマンドを投入 |

とりあえず vSRX から config の一覧を取得するサンプルをみてみましょう。

```py
from netmiko import ConnectHandler

SHOW_CONF = "show configuration | display set"


def create_conn(port: str) -> dict:
    return {
        "device_type": "juniper",
        "host": "localhost",
        "username": "root",
        "password": "Juniper",
        "port": port,
    }


if __name__ == "__main__":
    vsrx1 = create_conn("2201")
    vsrx1_conn = ConnectHandler(**vsrx1)
    print(vsrx1_conn.send_command(SHOW_CONF))
```

上記を実行した結果が次の通りです。

```sh
$ python main.py
set version 12.1X47-D15.4
set system host-name vsrx1
...
set security forwarding-options family mpls mode packet-based
```

このように接続先のパラメータを与えて、帰ってきたオブジェクトからメソッドを呼び出します。コマンドはベンダの接続先に合わせます。
# ソースを覗く

README では最低限しか記載していないのでソースを覗きましょう。面白そうなところを編集しながら抜粋しています。

## 接続について

ライブラリのエントリーポイントは ConnectHandler です。

1. 引数で渡した dict の`device_type`をサポートプラットフォーム一覧にあるか確認
2. なければサポートしているプラットフォーム一覧を返す
3. 成功したら ConnectionClass(=ssh_dispatcher) を返す

```py
def ConnectHandler(*args, **kwargs):
    device_type = kwargs["device_type"]
    if device_type not in platforms:
        if device_type is None:
            msg_str = platforms_str
        else:
            msg_str = telnet_platforms_str if "telnet" in device_type else platforms_str
        raise ValueError(
            "Unsupported 'device_type' "
            "currently supported platforms are: {}".format(msg_str)
        )
    ConnectionClass = ssh_dispatcher(device_type)
    return ConnectionClass(*args, **kwargs)
```

続いて ssh_dispatcher をみると、プラットフォームに応じたクラスを返すようですね。参考に Juniper で掘っていきます。`juniper` or `juniper_junos` を`device_type`で与えることで接続できそうです。ScreenOS 懐かしいですね・・・。

```py
def ssh_dispatcher(device_type):
    """Select the class to be instantiated based on vendor/platform."""
    return CLASS_MAPPER[device_type]

CLASS_MAPPER_BASE = {
    ...
    "juniper": JuniperSSH,
    "juniper_junos": JuniperSSH,
    "juniper_screenos": JuniperScreenOsSSH,
    ...
}
```

次に `JuniperSSH` をみると、`JunperBase` をオーバーライドしています。`JuniperBase` をみると、各種操作するためのメソッドが実装されています。ここでは接続に焦点を当てて基底クラスの`BaseConnection` をみてみます。

```py
class JuniperSSH(JuniperBase):
    pass

class JuniperBase(BaseConnection):
    """
    Implement methods for interacting with Juniper Networks devices.

    Disables `enable()` and `check_enable_mode()`
    methods.  Overrides several methods for Juniper-specific compatibility.
    """
    ...
```

ここが与えるパラメーターのリストですね。もっとたくさんのオプションがありますが、パスワードか、鍵かと思うので、必要に応じて与えることで調整できそうです。

```py
class BaseConnection(object):
    def __init__(
        self,
        ip="",
        host="",
        username="",
        password=None,
        secret="",
        port=None,
        device_type="",
        use_keys=False,
        key_file=None,
        ssh_config_file=None,
        ...
    ):
```

## 実装について

基本的な実装は`BaseConnection`に作成されているので、ベンダによる違いを各クラスでオーバライドして吸収しています。例えば Junos では`Operation Mode` -> `Configuration Mode`の 2 種類ですが、IOS では3種類かとかと思います。そのため、Override 先の`JuniperBase`では不要なので空にしています。

```py
class JuniperBase(BaseConnection):
    ...
    def enable(self, *args, **kwargs):
        """No enable mode on Juniper."""
        pass
    ...
```

一方、サンプルで使用した`send_command()`は`BaseConnection`の実装をそのまま使っています。ターゲットに対して入力コマンドを投げてレスポンスを受け取るだけなので、共通化できるんでしょうね。

```py
class BaseConnection(object):
    @select_cmd_verify
    def send_command(
        self,
        command_string,
        ...
    ):
        """Execute command_string on the SSH channel using a pattern-based mechanism. Generally
        used for show commands.
        ...
```

もう少し独自の実装についてみていきます。設定の変更を考えると、Junos では代表的な`commit` 機能があります。これは`BaseConnection`では空の実装になっており、`JuniperBase`でオーバーライドして実装されています。

```py
class JuniperBase(BaseConnection):
    ...
    def commit(
        self,
        confirm=False,
        confirm_delay=None,
        check=False,
        comment="",
        and_quit=False,
        delay_factor=1,
    ):
```

長いので、中身の細かい実装は直接みていただきたいのですが、commit利用における組み合わせを整理します。

| Junos                         | Python                                |
| ----------------------------- | ------------------------------------- |
| commit check                  | commit(check=True)                    |
| commit confirmed 5            | commit(confirm=True, confirm_delay=5) |
| commit comment "first commit" | commit(comment="first commit")        |
| commit and-quit               | commit(and_quit=True)                 |

このように基本的な操作ができる実装がなされています。

# 使用例

実際にいくつか試してみました。

## 情報を取得

基本形です。

```py
from netmiko import ConnectHandler

SHOW_CONF = "show configuration | display set"


def create_conn(port: str) -> dict:
    return {
        "device_type": "juniper",
        "host": "localhost",
        "username": "root",
        "password": "Juniper",
        "port": port,
    }


if __name__ == "__main__":
    vsrx1 = create_conn("2201")
    vsrx2 = create_conn("2202")
    vsrx1_conn = ConnectHandler(**vsrx1)
    vsrx2_conn = ConnectHandler(**vsrx2)
    print(vsrx1_conn.send_command(SHOW_VERSION))
    print(vsrx2_conn.send_command(SHOW_VERSION))
    vsrx1_conn.disconnect()
    vsrx2_conn.disconnect()
```

次のように出力結果を得られました。

```sh
$ python show_version.py 
 
Hostname: vsrx1
Model: firefly-perimeter
JUNOS Software Release [12.1X47-D15.4]

Hostname: vsrx2
Model: firefly-perimeter
JUNOS Software Release [12.1X47-D15.4]
```

## 設定を追加

設定を変更してみます。

```py
from netmiko import ConnectHandler
import time

SHOW_OSPF = "show ospf neighbor"


def create_conn(port: str) -> dict:
    return {
        "device_type": "juniper",
        "host": "localhost",
        "username": "root",
        "password": "Juniper",
        "port": port,
    }


def create_ospf(n: str) -> list:
    return [
        f"set interfaces ge-0/0/1 unit 0 family inet address 192.168.0.{n}/30",
        f"set interfaces lo0 unit 0 family inet address 10.0.0.{n}/32",
        f"set routing-options router-id 10.0.0.{n}",
        "set protocols ospf area 0.0.0.0 interface lo0.0 passive",
        "set protocols ospf area 0.0.0.0 interface ge-0/0/1.0"
    ]


if __name__ == "__main__":
    vsrx1 = create_conn("2201")
    vsrx2 = create_conn("2202")
    vsrx1_conn = ConnectHandler(**vsrx1)
    vsrx2_conn = ConnectHandler(**vsrx2)
    vsrx1_conn.send_config_set(create_ospf("1"))
    vsrx2_conn.send_config_set(create_ospf("2"))
    vsrx1_conn.commit()
    vsrx2_conn.commit()
    vsrx1_conn.disconnect()
    vsrx2_conn.disconnect()

    vsrx1_conn = ConnectHandler(**vsrx1)
    vsrx2_conn = ConnectHandler(**vsrx2)
    print(vsrx1_conn.send_command(SHOW_OSPF))
    print(vsrx2_conn.send_command(SHOW_OSPF))
    vsrx1_conn.disconnect()
    vsrx2_conn.disconnect()
```

次のような出力結果を得られます。

```sh
$ python netmiko_lab/main.py
Address          Interface              State     ID               Pri  Dead
192.168.0.2      ge-0/0/1.0             Full      10.0.0.2         128    33

Address          Interface              State     ID               Pri  Dead
192.168.0.1      ge-0/0/1.0             Full      10.0.0.1         128    32
```

複数のコマンドは`send_config_set`を利用します。`config_commands`は iterable な要素を取れるため list で渡します。わざわざセッションをつなぎ直しているのは、 commit のレスポンスを待っている間に session が timeout してしまうめ、正常に切断してから接続し直しています。これが嫌だったら parameter に各種 timeout の timer が用意されているので、その調整が必要かもしれません。

```py
class BaseConnection(object):
    ...
    def send_config_set(
        self,
        config_commands=None,
        ...
    ):
        """
        Send configuration commands down the SSH channel.

        config_commands is an iterable containing all of the configuration commands.
        The commands will be executed one after the other.
        ...
```

ちなみに、send_config_set()を print()すると CLI 画面のログも取得できます。

## ファイル読み込み

Config ファイルも読み込めると便利ですね。`send_config_from_file`をその場合に使います。

```sh
$ cat load_conf.conf
set interfaces ge-0/0/1 unit 0 family inet address 192.168.0.1/30
set interfaces lo0 unit 0 family inet address 10.0.0.1/32
set routing-options router-id 10.0.0.1
set protocols ospf area 0.0.0.0 interface lo0.0 passive
set protocols ospf area 0.0.0.0 interface ge-0/0/1.0
```

```py
from netmiko import ConnectHandler
import time


def create_conn(port: str) -> dict:
    return {
        "device_type": "juniper",
        "host": "localhost",
        "username": "root",
        "password": "Juniper",
        "port": port,
    }


if __name__ == "__main__":
    vsrx1 = create_conn("2201")
    vsrx1_conn = ConnectHandler(**vsrx1)
    vsrx1_conn.send_config_from_file("load_conf.conf")
    vsrx1_conn.commit()
    vsrx1_conn.disconnect()
```

実装を見てみると指定したファイルを open して、`send_config_set`で設定をしてくれます。

```py
class BaseConnection(object):
    ...
    def send_config_from_file(self, config_file=None, **kwargs):
        with io.open(config_file, "rt", encoding="utf-8") as cfg_file:
            return self.send_config_set(cfg_file, **kwargs)
```

コンフィグの入れ替えで使うと便利かもしれませんね。

## ファイル転送

ファイル転送機能も用意されていますので、version up 用の OS を各装置にアップロードなどもできそうですね。

```py
from netmiko import ConnectHandler, FileTransfer


def create_conn(port: str) -> dict:
    return {
        "device_type": "juniper_junos_ssh",
        "host": "localhost",
        "username": "root",
        "password": "Juniper",
        "port": port,
    }


if __name__ == "__main__":
    vsrx1 = create_conn("2201")
    vsrx1_conn = ConnectHandler(**vsrx1)

    with FileTransfer(vsrx1_conn,
                      source_file="load_conf.conf",
                      dest_file="load_conf.conf") as scp_transfer:
        if not scp_transfer.check_file_exists():
            if not scp_transfer.verify_space_available():
                raise ValueError("Insufficient space available on remote device")

            scp_transfer.transfer_file()
```

ファイルがちゃんと保存されていますね。

```sh
root@vsrx1> file list /var/tmp/ | grep conf
load_conf.conf
```

# まとめ

いかがでしょうか？マルチベンダーな環境に対して、入り口を`統一できる`のは便利そうです。
ただ、以下の課題を感じました。

- 出力結果のパースは別途必要
- 結局ベンダーごとのコマンド体系は理解しておく必要がある
- Connection は RDB (最低 csv)などに格納して取得するよう作り込みは必要

特にパースがしんどそうですが、[NAPALM](https://github.com/napalm-automation/napalm)があると聞いたことがあるので、そのうち試してみようと思います。


試したいけど環境がない方はこちら([無料で JUNOS を体感しよう(Vagrant + vSRX)](https://qiita.com/sourjp/items/4a2050cbdb2ba5d7a4cf))もご参考ください。

# 参考

[Program Talk](https://programtalk.com/python-examples/netmiko.FileTransfer/)