Azure の Public IP Ranges から IOS の ACL 設定を生成

本記事は Microsoft [Azure Tech Advent Calendar 2022](https://qiita.com/advent-calendar/2022/microsoft-azure-tech) の 15 日目の記事です。

Azure では、Azure 上で利用され得る Public IP (Global IP) の範囲を公開しています。
今回は上記の Public IP 範囲をもとに Cisco IOS の ACL 設定を自動生成する Python スクリプトを紹介します。

ネットワーク自動化や Azure SDK for Python をいじる際のチュートリアルとしても手頃な題材かと思うので、これらに興味のある方も是非ご覧ください。

# はじめに
Azure では、以下のサイトで Azure 上で利用され得る Public IP の範囲を公開しています。
[Azure IP Ranges and Service Tags – Public Cloud](https://www.microsoft.com/en-us/download/details.aspx?id=56519)

Public IP は サービスタグ 毎に公開されており、例えば AzureActiveDirectory サービスタグであれば 2022/12/13 時点で以下の様な形式で IP 範囲が記載されています。
```json:json
    {
      "name": "AzureActiveDirectory",
      "id": "AzureActiveDirectory",
      "properties": {
        "changeNumber": 13,
        "region": "",
        "regionId": 0,
        "platform": "Azure",
        "systemService": "AzureAD",
        "addressPrefixes": [
          "13.64.151.161/32",
          "13.66.141.64/27",
          "13.67.9.224/27",
        ---一部省略---
          "2603:1047:1::/48",
          "2603:1056:2000::/48",
          "2603:1057:2::/48"
        ],
        "networkFeatures": [
          "API",
          "NSG",
          "UDR",
          "FW",
          "VSE"
        ]
      }
    },
```

時々、上記の IP 範囲をベースに通信制御を適用したいといった要件を耳にします。

Azure 上であれば NSG や Azure Firewall によって通信制御を自動化出来ますが、オンプレミス上の機器はそうもいきません。

本記事の目的は、上記の Public IP 範囲から以下の様な Cisco IOS の ACL 設定を生成することです。
```console:Config
ip access-list extended IPv4AzureActiveDirectoryRanges
 10 permit ip any 13.64.151.161 0.0.0.0
 11 permit ip any 13.66.141.64 0.0.0.31
 12 permit ip any 13.67.9.224 0.0.0.31  …続く

ipv6 access-list IPv6AzureActiveDirectoryRanges
 sequence 10 permit ip any 2603:1047:1::/48
 sequence 11 permit ip any 2603:1056:2000::/48
 sequence 12 permit ip any 2603:1057:2::/48 …続く
```

## コードサンプル
以下の GitHub にアップロードしました。
[https://github.com/atusi0628/pub_cisco_automation_with_python](https://github.com/atusi0628/pub_cisco_automation_with_python)

本記事では上記のコードを順を追って解説していきます。
また、上記コードはあくまでサンプルですので、利用は自己責任でお願いします。


## ACL 設定自動生成の全体像
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2988457/01539382-a6df-8092-9106-a0d398ae66fd.png)

以下に各ステップ毎に重要なコンポーネントを記載します。

1. リクエストを送信し、Public IP 範囲を取得
    * __Azure SDK for Python__ -> API を叩く操作を簡素化するために利用。
    * __Service Principal__ -> API を叩く際の認証に利用。
    * __Service Tag Discovery API__ -> Public IP 範囲の取得に利用。

2. IOS の ACL 設定を生成
    * __Jinja2__ -> ACL 設定の生成に利用。

3. ACL 設定を投入
    * __Netmiko__ -> IOS への SSH & 設定投入に利用。

# 1. リクエストを送信し、Public IP 範囲を取得
リクエストを送信し、Public IP 範囲を取得するために以下のコードを利用します。
```python:Python
from azure.identity import ClientSecretCredential
from azure.mgmt.network import NetworkManagementClient

# Credentials for Azure
SUBSCRIPTION_ID = "<Your subscription id>"
TENANT_ID = "<Your AAD tenant id>"
CLIENT_ID = "<Your app id obtain from service principal>"
SECRET = "<Your app secret obtain from service principal>"

credential = ClientSecretCredential(TENANT_ID, CLIENT_ID, SECRET)
network_client = NetworkManagementClient(credential, SUBSCRIPTION_ID)
result = network_client.service_tags.list('japaneast')
```

## Azure SDK for Python について
Azure SDK for Python は、 Python から Azure リソースの管理やプロビジョニングを行うための SDK です。
[Python 用 Azure ライブラリ (SDK) を使用する](https://learn.microsoft.com/ja-jp/azure/developer/python/sdk/azure-sdk-overview)

Azure の REST API を利用する際の認証処理や API を叩く処理を容易に実装できます。
今回はこの SDK を利用して、後述する Service Principal による認証や、Service Tag Discovery API を叩く処理を実装します。

## Service Principal について
Azure の REST API を叩く際、主に以下の 4 つの認証方法が用意されており、今回は Service Principal による認証を利用します。
[Azure Identity client library for Python](https://github.com/Azure/azure-sdk-for-python/tree/main/sdk/identity/azure-identity#credential-classes)

* Authenticate Azure hosted applications
-> 環境変数やVaultから資格情報を読み取って認証/認可する方式

* __Authenticate service principal__
-> サービスプリンシパルの情報から認証/認可する方式

* Authenticate users
-> ブラウザやデバイスコードによって認証/認可する方法

* Authenticate via development tools
-> Azure CLI や VSCode を介して認証/認可する方法

### Service Principal とは
Service Principal は、 Azure リソース (API) にアクセスするアプリケーションを識別をし、認証やアクセス制御を行うための仕組みです。
[Azure CLI で Azure サービス プリンシパルを作成する](https://learn.microsoft.com/ja-jp/cli/azure/create-an-azure-service-principal-azure-cli)

パスワードによる認証と証明書による認証を選択可能ですが、今回は手軽なパスワード認証を採用します。
上記ドキュメントにも記載がありますが、以下の通り認証情報を発行します。
```console:Command line
// login
az login

// Create Service Principal
az ad sp create-for-rbac --name pyApp
{
  "appId": “xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx”,
  "displayName": “pyApp",
  "password": “yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy”,
  "tenant": “zzzzzzzz-zzzz-zzzz-zzzzz-zzzzzzzzzzzz”
}

// Assign Contoributor Role
az role assignment create --assignee <appId> --role Contributor
```

### Service Principal を用いて認証するコード
事前に以下の認証用のライブラリをインストールしておきます。
```console:Command line
pip install azure-identity
```

```python:Python
from azure.identity import ClientSecretCredential
from azure.mgmt.network import NetworkManagementClient

# Credentials for Azure
SUBSCRIPTION_ID = "<Your subscription id>"
TENANT_ID = "<Your AAD tenant id>"
CLIENT_ID = "<Your app id obtain from service principal>"
SECRET = "<Your app secret obtain from service principal>"

credential = ClientSecretCredential(TENANT_ID, CLIENT_ID, SECRET)
```

先ほど生成した認証情報を ClientSecretCredential の引数に設定することで認証が実施され、戻り値として API 操作用のクレデンシャルが渡されます。

今後はこのクレデンシャルを利用して API を叩きます。

上記の認証処理については下記ドキュメントが非常に分かりやすいです。
[オンプレミスでホストされている Python アプリから Azure リソースに対して認証する](https://learn.microsoft.com/ja-jp/azure/developer/python/sdk/authentication-on-premises-apps?tabs=azure-cli)

## Service Tag Discovery API について
Service Tag Discovery API は、前述した Azure の Public IP 範囲を取得するための API です。
[Service Tags - List](https://learn.microsoft.com/ja-jp/rest/api/virtualnetwork/service-tags/list?tabs=HTTP)

### Service Tag Discovery API を叩くコード
Azure の REST API はサービス毎に API が分かれており、Service Tag Discovery API を利用するには以下のライブラリを事前にインストールします。
```console:Command line
pip install azure-mgmt-network
```

```python:Python
from azure.identity import ClientSecretCredential
from azure.mgmt.network import NetworkManagementClient

# Issue credential
credential = ClientSecretCredential(TENANT_ID, CLIENT_ID, SECRET)
# Authntication 
network_client = NetworkManagementClient(credential, SUBSCRIPTION_ID)
# Get Public IP ranges from Service Tag Discovery API
result = network_client.service_tags.list('japaneast')
```

先ほど取得したクレデンシャルと自身のサブスクリプション ID を指定し、認証を行います。

引数にリージョン('japaneast')を指定していますが、API の仕様上指定が必須なだけで、取得できる Public IP 範囲をリージョンに限定するようなものではありません。

## 取得した Public IP 範囲の中身を確認してみる

```python:Python
result = network_client.service_tags.list('japaneast')
target_name = "AzureActiveDirectory"
for item in result.values:
    if item.name == target_tag_name:
        address_prefixes = item.properties.address_prefixes
        break
print(address_prefixes)
```

サービスタグ AzureActiveDirectory に含まれる address_prefixes を抽出しています。
以下の通り、IPv4 と IPv6 アドレスが混在したリストが応答されます。

```json:json
[
    '13.64.151.161/32', 
    '13.66.141.64/27', 
    '13.67.9.224/27',
    ---一部省略---
    '2603:1006:2000::/48', 
    '2603:1007:200::/48', 
    '2603:1016:1400::/48',
]
```

# 2. IOS の ACL 設定を生成
Python のテンプレートエンジンである Jinja2 を利用し、ACL 設定を生成するコードを書きます。
```python:Python
import ipaddress
import jinja2

# Target service tag name
target_tag_name = "<Target service tag name>"

# Create lists used to generate config file
ipv4_sequence = 10
ipv6_sequence = 10
ipv4_list = []
ipv6_list = []

for item in address_prefixes:
    address_object = ipaddress.ip_network(item)

    # Create list for IPv4 ACL config
    if address_object.version == 4:
        # Get IP address
        address = str(address_object.network_address)
        # Get wildcard mask
        wildcard = str(address_object.hostmask)
        ipv4_list.append({'sequence':ipv4_sequence, 'address': address, 'wildcard': wildcard})
        ipv4_sequence += 1
    # Create list for IPv6 ACL config
    else:
        # Get IP address and subnet mask
        address_mask = str(address_object)
        ipv6_list.append({'sequence': ipv6_sequence, 'address_mask': address_mask})
        ipv6_sequence += 1

# Generate config from ipv4_list
with open('ios_acl_ipv4.conf', 'w') as f:
    env = jinja2.Environment(loader=jinja2.FileSystemLoader('./templates/'))
    template = env.get_template('ios_acl_ipv4.j2')
    result = template.render(ipv4_list=ipv4_list, target_tag_name=target_tag_name)
    f.write(result)

# Generate config from ipv6_list
with open('ios_acl_ipv6.conf', 'w') as f:
    env = jinja2.Environment(loader=jinja2.FileSystemLoader('./templates/'))
    template = env.get_template('ios_acl_ipv6.j2')
    result = template.render(ipv6_list=ipv6_list, target_tag_name=target_tag_name)
    f.write(result)
```

```jinja:Jinja2 template - ios_acl_ipv4.j2
no ip access-list extended IPv4{{ target_tag_name }}Ranges
ip access-list extended IPv4{{ target_tag_name }}Ranges
{%- for item in ipv4_list %}
 {{ item.sequence }} permit ip any {{ item.address }} {{ item.wildcard }}
{%- endfor -%}
```

```jinja:Jinja2 template - ios_acl_ipv6.j2
no ipv6 access-list IPv6{{ target_tag_name }}Ranges
ipv6 access-list IPv6{{ target_tag_name }}Ranges
{%- for item in ipv6_list %}
 sequence {{ item.sequence }} permit ip any {{ item.address_mask }}
{%- endfor -%}
```

## ipaddress ライブラリによる IP アドレスの操作
ipadress ライブラリを使うと、引き渡した IP アドレスが IPv4 or IPv6 かの判定や、サブネットマスクの抽出、ワイルドカードマスクの計算等を容易に行えます。
[ipaddressモジュールの紹介](https://docs.python.org/ja/3/howto/ipaddress.html)

先ほど確認したように、Service Tag Discovery API からは IPv4 と IPv6 アドレスが混在したリストが応答されるのに加え、Cisco IOS の IPv4 ACL 設定はワイルドカードマスクの計算が必要なため、本ライブラリを使用します。

事前に ipaddress ライブラリをインストールしておきます。
```console
pip3 install ipaddress
```

```python:Python
import ipaddress

# Target service tag name
target_tag_name = "<Target service tag name>"

# Create lists used to generate config file
ipv4_sequence = 10
ipv6_sequence = 10
ipv4_list = []
ipv6_list = []

for item in address_prefixes:
    address_object = ipaddress.ip_network(item)

    # Create list for IPv4 ACL config
    if address_object.version == 4:
        # Get IP address
        address = str(address_object.network_address)
        # Get wildcard mask
        wildcard = str(address_object.hostmask)
        ipv4_list.append({'sequence':ipv4_sequence, 'address': address, 'wildcard': wildcard})
        ipv4_sequence += 1
    # Create list for IPv6 ACL config
    else:
        # Get IP address and subnet mask
        address_mask = str(address_object)
        ipv6_list.append({'sequence': ipv6_sequence, 'address_mask': address_mask})
        ipv6_sequence += 1
```

以下の部分で IPv4 or IPv6 アドレスの判定を行っています。
```python
if address_object.version == 4:
else:
```

### IPv4 ACL の場合
IPv4 の場合、以下の部分でシーケンス番号、IP アドレス、ワイルドカードマスクを取得しています。
```python
# Get IP address
address = str(address_object.network_address)
# Get wildcard mask
wildcard = str(address_object.hostmask)
ipv4_list.append({'sequence':ipv4_sequence, 'address': address, 'wildcard': wildcard})
ipv4_sequence += 1
```

例えば、AzureActiveDirectory サービスタグを指定して、最初に 13.64.151.161/32 という IP 範囲が引き渡された場合、各変数には以下のような値が入ります。
* target_tag_name = AzureActiveDirectory
* sequece = 10
* address = 12.64.151.161
* wildcard = 0.0.0.0

将来的に上記の値は、以下のように Cisco IOS のコンフィグに落とし込みます。
```console
ip access-list extended IPv4AzureActiveDirectoryRanges
 10 permit ip any 13.64.151.161 0.0.0.0
```

### IPv6 ACL の場合
IPv6 の場合、以下の部分でシーケンス番号、Prefix (IP アドレス + サブネットマスク)を取得しています。
```python:Python
# Get IP address and subnet mask
address_mask = str(address_object)
ipv6_list.append({'sequence': ipv6_sequence, 'address_mask': address_mask})
ipv6_sequence += 1
```

例えば、AzureActiveDirectory サービスタグを指定して、最初に 13.64.151.161/32 という IP 範囲が引き渡された場合、各変数には以下のような値が入ります。
* target_tag_name = AzureActiveDirectory
* sequece = 10
* address_mask = 2603:1006:2000::/48

将来的に上記の値は、以下のように Cisco IOS のコンフィグに落とし込みます。
```console
ipv6 access-list IPv6AzureActiveDirectoryRanges
 sequence 10 permit ip any 2603:1006:2000::/48
```

## Jinja2 とは
Python で利用可能なテンプレートエンジンで、Python コードから変数等を受け取り、それをもとに動的にファイルを生成することが出来ます。
[Template Designer Documentation](https://jinja.palletsprojects.com/en/3.0.x/templates/)

ざっくりですが、主な構文は以下の通りです。
* {% %} -> Statement (for 文や if 文を埋め込む際に使用)
* {{ }} -> Expressions (変数を展開する際に使用)
* {# #} -> Comments (コメントを記述する際に使用)

以下の通り、事前に jinja2 ライブラリをインストールしておきます。
```console
pip3 install jinja2
```

### IPv4 ACL 設定を生成

```python:Python
# Generate config from ipv4_list
with open('ios_acl_ipv4.conf', 'w') as f:
    env = jinja2.Environment(loader=jinja2.FileSystemLoader('./templates/'))
    template = env.get_template('ios_acl_ipv4.j2')
    result = template.render(ipv4_list=ipv4_list, target_tag_name=target_tag_name)
    f.write(result)
```

以下の部分でコンフィグファイル生成に使用する Jinja2 テンプレートを指定しています。
```python
env = jinja2.Environment(loader=jinja2.FileSystemLoader('./templates/'))
template = env.get_template('ios_acl_ipv4.j2')
```

以下の部分で変数を引き渡してレンダリングをしています。
ipv4_list は 先ほど生成した IPv4 ACL 設定用の辞書を引き渡し、target_tag_name には Public IP 範囲を取得したサービスタグ名が入り、ACL 名の設定に利用します。
```python
result = template.render(ipv4_list=ipv4_list, target_tag_name=target_tag_name)
```

テンプレートは以下の様になっており、ACL 名および ACL の各エントリを、引き渡した変数に応じて動的に生成できます。
```jinja:Jinja2 template - ios_acl_ipv4.j2
no ip access-list extended IPv4{{ target_tag_name }}Ranges
ip access-list extended IPv4{{ target_tag_name }}Ranges
{%- for item in ipv4_list %}
 {{ item.sequence }} permit ip any {{ item.address }} {{ item.wildcard }}
{%- endfor -%}
```
例えば、 AzureActiveDirectory サービスタグを指定していた場合は、以下のようなコンフィグファイルが出力されます。
```console
no ip access-list extended IPv4AzureActiveDirectoryRanges
ip access-list extended IPv4AzureActiveDirectoryRanges
 10 permit ip any 13.64.151.161 0.0.0.0
 11 permit ip any 13.66.141.64 0.0.0.31
 12 permit ip any 13.67.9.224 0.0.0.31 ...続く
```
※ 先頭に no ip access-list を入れているのは、2 回目 3 回目を実行した際に ACL 設定を競合させないためです。
※ 非常に雑なので、実運用で使う際は既存設定と新規設定の diff を取り、差分だけを反映させるようなロジックを組んだほうが良いと思います。


### IPv6 ACL 設定を生成
```python:Python
# Generate config from ipv6_list
with open('ios_acl_ipv6.conf', 'w') as f:
    env = jinja2.Environment(loader=jinja2.FileSystemLoader('./templates/'))
    template = env.get_template('ios_acl_ipv6.j2')
    result = template.render(ipv6_list=ipv6_list, target_tag_name=target_tag_name)
    f.write(result)
```

以下の部分でコンフィグファイル生成に使用する Jinja2 テンプレートを指定しています。
```python
env = jinja2.Environment(loader=jinja2.FileSystemLoader('./templates/'))
template = env.get_template('ios_acl_ipv6.j2')
```

以下の部分で変数を引き渡してレンダリングをしています。
ipv6_list は 先ほど生成した IPv4 ACL 設定用の辞書を引き渡し、target_tag_name には Public IP 範囲を取得したサービスタグ名が入り、ACL 名の設定に利用します。
```python
result = template.render(ipv6_list=ipv6_list, target_tag_name=target_tag_name)
```

テンプレートは以下の様になっており、ACL 名および ACL の各エントリを、引き渡した変数に応じて動的に生成できます。
```jinja:Jinja2 template - ios_acl_ipv6.j2
no ipv6 access-list IPv6{{ target_tag_name }}Ranges
ipv6 access-list IPv6{{ target_tag_name }}Ranges
{%- for item in ipv6_list %}
 sequence {{ item.sequence }} permit ip any {{ item.address_mask }}
{%- endfor -%}
```

例えば、 AzureActiveDirectory サービスタグを指定していた場合は、以下のようなコンフィグファイルが出力されます。
```console
no ipv6 access-list IPv6AzureActiveDirectoryRanges
ipv6 access-list IPv6AzureActiveDirectoryRanges
 sequence 10 permit ip any 2603:1006:2000::/48
 sequence 11 permit ip any 2603:1007:200::/48
 sequence 12 permit ip any 2603:1016:1400::/48 ...続く
```

# 3. ACL 設定を投入
Cisco IOS への設定投入には Python の Netmiko ライブラリを利用します。

## Netmiko とは
Netmiko は、Python からネットワーク機器への SSH/Telnet 接続や、 コマンド/コンフィグ 投入等の操作を容易に行うためのライブラリです。
[https://github.com/ktbyers/netmiko](https://github.com/ktbyers/netmiko)

設定の投入方法はいくつかありますが、今回はコンフィグファイルを一括で投入する方法を採用します。
* send_command … コマンドを1つ送信
* send_config_set … リスト内のコマンドを先頭から送信
* __send_config_from_file__ … configファイルの内容を先頭から送信
* send_command_timing … 指定の時間コマンドのアウトプットを待機

以下のライブラリを事前にインストールしておきます。
```console
pip3 install netmiko
```

### Netmiko による SSH 接続 + コンフィグ投入
```python:Python
from netmiko import ConnectHandler

# IOS information and credential
cisco_ios = {
        'device_type': 'cisco_ios',
        'host': '<Your management ip>',
        'username': '<Your username>',
        'password': '<Your password>',
        'port': 22
        }

# Connect IOS
ssh = ConnectHandler(**cisco_ios)
ssh.enable()

# Send config
ssh.send_config_from_file('ios_acl_ipv4.conf')
ssh.send_config_from_file('ios_acl_ipv6.conf')

# Close connection
ssh.disconnect()
```

以下の部分で認証情報を定義し、SSH 接続及び特権モードへの昇格を行っています。
認証情報の形式については、下記にサンプルがあります。
[https://github.com/ktbyers/netmiko#create-a-dictionary-representing-the-device](https://github.com/ktbyers/netmiko#create-a-dictionary-representing-the-device)
```python
# IOS information and credential
cisco_ios = {
        'device_type': 'cisco_ios',
        'host': '<Your management ip>',
        'username': '<Your username>',
        'password': '<Your password>',
        'port': 22
        }

# Connect IOS
ssh = ConnectHandler(**cisco_ios)
ssh.enable()
```

以下の部分で先ほど生成したコンフィグファイルを投入し、 SSH 接続を切断しています。
```python
# Send config
ssh.send_config_from_file('ios_acl_ipv4.conf')
ssh.send_config_from_file('ios_acl_ipv6.conf')

# Close connection
ssh.disconnect()
```

## IOS 側でコンフィグが投入されていることを確認
以下の通り、設定が反映されています。
```console
show running-config | section ip access-list

ip access-list extended IPv4AzureActiveDirectoryRanges
 10 permit ip any host 13.64.151.161
 11 permit ip any 13.66.141.64 0.0.0.31
 12 permit ip any 13.67.9.224 0.0.0.31
 13 permit ip any 13.69.66.160 0.0.0.31
 14 permit ip any 13.69.229.96 0.0.0.31
 15 permit ip any 13.70.73.32 0.0.0.31 ...続く


show running-config | section ipv6 access-list
ipv6 access-list IPv6AzureActiveDirectoryRanges
 sequence 10 permit ipv6 any 2603:1006:2000::/48
 sequence 11 permit ipv6 any 2603:1007:200::/48
 sequence 12 permit ipv6 any 2603:1016:1400::/48
 sequence 13 permit ipv6 any 2603:1017::/48
 sequence 14 permit ipv6 any 2603:1026:3000::/48
 sequence 15 permit ipv6 any 2603:1027:1::/48 ...続く
```

# まとめ
Azure の Public IP アドレス範囲を基に、オンプレミスの機器で通信制御を行う構成を時々耳にするので、今回はそれを自動化するスクリプトを紹介してみました。

紹介したスクリプトは冪等性の確保やエラー処理が甘いので、もし利用する際は運用に耐えうるカスタマイズをしてもらえればと思います。

ネットワーク自動化や Azure SDK for Python をいじる際のチュートリアルとしても手頃な題材かと思うので、これらに興味のある方も是非お試しください！

# 参考情報
* [紹介したスクリプトのソースコード](https://github.com/atusi0628/pub_cisco_automation_with_python)
* [Azure Tech Advent Calendar 2022](https://qiita.com/advent-calendar/2022/microsoft-azure-tech)
* [Azure IP Ranges and Service Tags – Public Cloud](https://www.microsoft.com/en-us/download/details.aspx?id=56519)
* [Azure SDK for Python について](https://learn.microsoft.com/ja-jp/azure/developer/python/sdk/azure-sdk-overview)
* [Python から Service Principal を利用した場合の認証について](https://learn.microsoft.com/ja-jp/azure/developer/python/sdk/authentication-on-premises-apps?tabs=azure-cli)
* [Service Tags Discovery API について](https://learn.microsoft.com/ja-jp/rest/api/virtualnetwork/service-tags/list?tabs=HTTP)
* [ipaddressモジュールの紹介](https://docs.python.org/ja/3/howto/ipaddress.html)
* [Jinja2 について](https://jinja.palletsprojects.com/en/3.0.x/templates/)
* [Netmiko について](https://github.com/ktbyers/netmiko)