Radkit でスイッチポートにつながっている機器を特定する

# はじめに
この記事はシスコの有志による Cisco Systems Japan Advent Calendar 2024 （一枚目） の 17日目として投稿しています。

2017年版: https://qiita.com/advent-calendar/2017/cisco
2018年版: https://qiita.com/advent-calendar/2018/cisco
2019年版: https://qiita.com/advent-calendar/2019/cisco
2020年版: https://qiita.com/advent-calendar/2020/cisco
2020年版(2枚目): https://qiita.com/advent-calendar/2020/cisco2
2021年版: https://qiita.com/advent-calendar/2021/cisco
2021年版(2枚目): https://qiita.com/advent-calendar/2021/cisco2
2022年版(1,2): https://qiita.com/advent-calendar/2022/cisco
2023年版: https://qiita.com/advent-calendar/2023/cisco
2024年版: https://qiita.com/advent-calendar/2024/cisco

# スイッチポートにつながっているもの

データセンター内にサーバを設置する場合、最終的にはイーサネットでスイッチのポートへ接続します。どのポートに何が繋がっているのか、もちろん接続時にインベントリー情報として、何らかの方法で記録しておく事が望ましいのですが、運用上完璧にそれが出来ていないといった話を、（こそっと）聞くことがあります。

その可能性が完全に拭えない場合には、「このスイッチには何が繋がっているんだ！？」という問いに答えるには、ケーブルを追いかけるしかありません。しかしながら、本番環境のケーブルになど触りたくないのが本心で、また、技術的、政治的、担当者の成績的（笑）にもまずい状況ということになると思います。

# ルータとスイッチの情報から追いかける

今日、イーサネット上で IP を使用して通信される場合がほとんどですので、一般的にもルータの ARP 情報とスイッチの Mac の情報を突き合わせることで、繋がっている機器の IP アドレスを特定することが可能です。

サーバがルータを経由して通信する際、ルータ上に IP-Mac アドレスのひも付き情報が ARP テーブルに記録され、通信中はそれが使用されます。また、スイッチ上の CAM MAC テーブルには、接続されているポートと Mac アドレスのひも付きが保存されています。それらの情報が今回の作業に必要なものとなります。

# トポロジー

この記事を作成するにあたって、以下のようなトポロジーを CML 上で作成してみました。

![topology.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/140504/c46c69b8-50f0-0989-3378-22c20cdb282a.png)

昨今では Spine-Leaf な形が増えており、Cisco としてもぜひ ACI などを使用して頂きたいですが、コア-ディストリビューション-アクセスな、3階層モデルもまだまだ使用されており、それを想定したものです。

アクセススイッチは2つのルータへ接続されており、どちらかのリンクがダウンしても接続性を維持します。サーバ (alpine-0 等) が使用するデフォルトゲートウェイは、HSRP や VRRP などを使用して冗長化され、アップリンク方向へは r1 又は r2 が active/standby として使用されます。ダウンリンク方向では、上位ルータのルーティングに依存しますが、両方が active/active で使われる可能性があります。

# Radkit Service の設置


管理ネットワーク上に Radkit Service のサーバを設置します。ルータ・スイッチは同管理ネットワークへ接続されており、radkit-service からは接続が可能となっています。

![radkit-service.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/140504/333fc3a5-0f05-957b-2823-1d4c974d9719.png)


また、4つのネットワーク機器について接続情報が登録されていますので、Radkit-Service 経由でそれぞれの機器を操作することが出来るようになりました。

![Screenshot 2024-12-15 at 23.00.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/140504/1fbbcd6d-00e2-40f7-0c51-75033e8bbdca.png)

# Radkit Client を使ってクラウドへ接続・操作する

Radkit Client をインストールすると、Python の radkit_client パッケージがインストールされます。それを使用しながら、基本的には以下の流れのコードを作成します。

- Cisco SSO に対して、ユーザ認証
- 指定された Service ID への接続
- Inventory 機器の操作

ユーザ認証についてはいくつか方法がありますが、本記事では token 認証を使用します。ユーザ認証を行った後、使用可能時間が有限な Token が配布されますので、以後はそれを使用することで、毎回認証を行う必要がなくなります。

以下の例では、接続後にオープンされる Websocket endpoint へ接続し、認証完了の通知を待っています。

```python
from radkit_client.sync import Client

with Client.create() as client:
  connect_data = client.oauth_connect_only(CLIENT_ID)
  ws = create_connection(str(connect_data.token_url))
  webbrowser.open(str(connect_data.sso_url))
  token = json.loads(ws.recv())['access_token']
```

その後、得られた Token を使って Radkit Cloud へログインし、Service へと接続します。

```python
  token_client = client.access_token_login(token)
  service = token_client.service(SERVICE_ID).wait()
```

以下の記事も参考にしてみてください。こちらでは、Certificate を使用したログイン方法について、紹介しております。

[RADKit でルータの状態確認を自動化してみた！](https://qiita.com/rsakaguchi/items/3a8a4be6a15f66207a49)

# ルータから arp 情報を取得する

ルータ r1 と r2 より、`show ip arp` の結果を取得します。実機上で取得される結果は、以下のようなものです。

例えば、IP　アドレス `192.168.1.10` は、MAC アドレス `5254.000c.136e` と紐づいています。

```
r1#show ip arp 
Protocol  Address          Age (min)  Hardware Addr   Type   Interface
Internet  10.1.2.1                -   5254.0018.d825  ARPA   GigabitEthernet2
Internet  10.10.0.1               -   5254.0017.caf5  ARPA   GigabitEthernet1
Internet  10.10.0.3             103   aabb.cc00.0520  ARPA   GigabitEthernet1
Internet  10.10.0.10             80   5254.0011.9600  ARPA   GigabitEthernet1
Internet  192.168.1.1             -   5254.001c.0925  ARPA   GigabitEthernet3
Internet  192.168.1.2           134   5254.000f.766d  ARPA   GigabitEthernet3
Internet  192.168.1.10            0   5254.000c.136e  ARPA   GigabitEthernet3
Internet  192.168.1.20            0   5254.0008.7123  ARPA   GigabitEthernet3
Internet  192.168.1.254           -   0000.0c07.ac01  ARPA   GigabitEthernet3
Internet  192.168.2.1             -   5254.000e.0b06  ARPA   GigabitEthernet4
Internet  192.168.2.10           77   5254.0006.f4a8  ARPA   GigabitEthernet4
r1#
```

これはテキストデータとなっており、パースして読み出す必要がありますが、PyAts の Genie が使用可能ですので、コードは簡易なもので完了します。

```python
devs = service.inventory['r1'].singleton()
devs.add(service.inventory['r2'].singleton())
res = devs.exec("show ip arp").wait()
result = radkit_genie.parse(res, os='iosxe')
r1_arp =result['r1']['show ip arp'].data
r2_arp =result['r2']['show ip arp'].data
```

このように、パースした情報を Dictionary に格納してくれますので、読み出しは簡単です。

- r1_arp

```json
{'_exclude': ['age'],
 'interfaces': {
   'GigabitEthernet1': {'ipv4': {'neighbors': {
     '10.10.0.1': {'age': '-',
       'ip': '10.10.0.1',
       'link_layer_address': '5254.0017.caf5',
       'origin': 'static',
       'protocol': 'Internet',
       'type': 'ARPA'},
     '10.10.0.10': {'age': '0',
       'ip': '10.10.0.10',
       'link_layer_address': '5254.0011.9600',
       'origin': 'dynamic',
       'protocol': 'Internet',
       'type': 'ARPA'},
     '10.10.0.3': {'age': '106',
       'ip': '10.10.0.3',
       'link_layer_address': 'aabb.cc00.0520',
       'origin': 'dynamic',
       'protocol': 'Internet',
       'type': 'ARPA'}}}},
...
```

Genie で対応しているコマンドについては、以下をご確認ください。

[Genie Parsers list](https://pubhub.devnetcloud.com/media/genie-feature-browser/docs/#/parsers)

# スイッチから mac テーブル情報を取得する

使用するコマンドは `show mac address-table` です。
実機から取得する内容は以下です。

```
sw1#show mac address-table                
          Mac Address Table
-------------------------------------------

Vlan    Mac Address       Type        Ports
----    -----------       --------    -----
  10    0000.0c07.ac01    DYNAMIC     Et0/0
  10    5254.0008.7123    DYNAMIC     Et1/0
  10    5254.000c.136e    DYNAMIC     Et0/3
  10    5254.000f.766d    DYNAMIC     Et0/1
  10    5254.001c.0925    DYNAMIC     Et0/0
Total Mac Addresses for this criterion: 5
sw1#
```

接続先デバイスやコマンドが違いますが、コードは上のルータで使用したものと同様です。

```python
devs = service.inventory['sw1'].singleton()
devs.add(service.inventory['sw2'].singleton())
res = devs.exec("show mac address-table").wait()
result = radkit_genie.parse(res, os='iosxe')
sw1_mac_table = result['sw1']['show mac address-table'].data
sw2_mac_table = result['sw2']['show mac address-table'].data
```

以下のように Directory として取得できます。

```json
{'mac_table': 
  {'vlans': 
    {'10': {'vlan': 10, 'mac_addresses': {
      '0000.0c07.ac01': {
        'mac_address': '0000.0c07.ac01', 
        'interfaces': {'Ethernet0/0': {
          'interface': 'Ethernet0/0', 'entry_type': 'dynamic'}}},
      '5254.0008.7123': {
        'mac_address': '5254.0008.7123',
        'interfaces': {'Ethernet1/0': {
          'interface': 'Ethernet1/0', 'entry_type': 'dynamic'}}},
      '5254.000c.136e': {
        'mac_address': '5254.000c.136e',
        'interfaces': {'Ethernet0/3': {
          'interface': 'Ethernet0/3', 'entry_type': 'dynamic'}}},
      '5254.000f.766d': {
        'mac_address': '5254.000f.766d',
        'interfaces': {'Ethernet0/1': {
          'interface': 'Ethernet0/1', 'entry_type': 'dynamic'}}},
      '5254.001c.0925': {
        'mac_address': '5254.001c.0925',
        'interfaces': {'Ethernet0/0': {
          'interface': 'Ethernet0/0', 'entry_type': 'dynamic'}}}
...
```

# ポートから見える mac アドレスに紐づく IP アドレスを探す

`sw1` の `Eth0/0` に繋がっているホストの IP アドレスは何でしょうか。
`show mac address-table` の出力から、その mac アドレスは `5254.0008.7123` であることが確認できます。

その mac アドレスと紐づく IP アドレスは、`show ip arp` コマンドの出力から、`192.168.1.20` であることがわかります。

# 自動化

マッピングを探すこの作業、ポート数が少ない間は手作業でも可能ですが、それが多い場合には大変時間もかかります。48ポートラインカードが10枚くらい刺さってたりすると、、、😱

せっかく Directory にデータとして扱えるようになりましたので、このまま続けてマッピングのコードも追加します。Python の Dictionary を使っての作業のみとなるため、詳細な説明は致しませんが、これらのデータがあれば、目的が達成可能であることをご理解頂けたらと思います。

結果、以下が得られました。

```
('sw1', '0000.0c07.ac01', 'Ethernet0/0', '192.168.1.254')
('sw1', '5254.0008.7123', 'Ethernet1/0', '192.168.1.20')
('sw1', '5254.000c.136e', 'Ethernet0/3', '192.168.1.10')
('sw1', '5254.000f.766d', 'Ethernet0/1', '192.168.1.2')
('sw1', '5254.001c.0925', 'Ethernet0/0', '192.168.1.1')
('sw2', '0000.0c07.ac02', 'Ethernet0/1', '192.168.2.254')
('sw2', '5254.0006.f4a8', 'Ethernet0/3', '192.168.2.10')
('sw2', '5254.000e.0b06', 'Ethernet0/0', '192.168.2.1')
('sw2', '5254.0014.f150', 'Ethernet1/0', '192.168.2.20')
('sw2', '5254.0016.f3a7', 'Ethernet0/1', '192.168.2.2')
```

# おわりに

ここまで書いておいて何ですが、この手法では完全なデータが作成出来るわけではありません。ルータの ARP 情報はデフォルトで 4時間、スイッチの Macテーブルはデフォルトで 300秒経つと消えてしまいます。常に通信が発生している環境であれば良いのですが、そうではない場合、事前に broadcast ping や、個々に ping をするなど、ルータ・スイッチに認識させるといったことが必要になるかも知れません。

# 今回作成したコード

参考情報としてご確認頂ければと思います。この方法が最善であるわけではありません。

```python
$ cat test.py
import webbrowser
from radkit_client.sync import Client, DeviceDict
import radkit_genie
from websocket import create_connection
import time
import json
import os
from datetime import datetime, timedelta


CLIENT_ID = "<CCO ID>"
SERVICE_ID = "xxxx-xxxx-xxxx"


def is_token_valid(file_path: str, max_age_seconds: int = 3600) -> bool:
    if not os.path.exists(file_path):
        print(f"File '{file_path}' does not exist.")
        return False

    current_time = time.time()
    file_mod_time = os.path.getmtime(file_path)
    file_age = current_time - file_mod_time

    if file_age < max_age_seconds:
        age_str = str(timedelta(seconds=int(file_age)))
        print(f"File '{file_path}' exists and is {age_str} old.")
        return True
    else:
        age_str = str(timedelta(seconds=int(file_age)))
        print(f"File '{file_path}' is too old ({age_str}). It must be less than {timedelta(seconds=max_age_seconds)} old.")
        return False

with Client.create() as client:
    if is_token_valid('token'):
        with open('token', 'r') as f:
            token = f.read()
    else:
        connect_data = client.oauth_connect_only(CLIENT_ID)
        ws = create_connection(str(connect_data.token_url))
        webbrowser.open(str(connect_data.sso_url))
        token = json.loads(ws.recv())['access_token']
        #  print('Token:', token)

        with open('token', 'w') as f:
            f.write(token)

    token_client = client.access_token_login(token)
    service = token_client.service(SERVICE_ID).wait()

    print(service.status)
    print(service.inventory)

    # ARP テーブル情報の取得
    devs = service.inventory['r1'].singleton()
    devs.add(service.inventory['r2'].singleton())
    res = devs.exec("show ip arp").wait()
    result = radkit_genie.parse(res, os='iosxe')
    r1_arp =result['r1']['show ip arp'].data
    r2_arp =result['r2']['show ip arp'].data

    # 2つのルータ上の情報を合わせる
    vlan10_arp = r1_arp['interfaces']['GigabitEthernet3']['ipv4']['neighbors']
    vlan10_arp.update(r2_arp['interfaces']['GigabitEthernet3']['ipv4']['neighbors'])

    vlan20_arp = r1_arp['interfaces']['GigabitEthernet4']['ipv4']['neighbors']
    vlan20_arp.update(r2_arp['interfaces']['GigabitEthernet4']['ipv4']['neighbors'])

    vlan10_mac_ip = [(x['link_layer_address'],x['ip']) for x in vlan10_arp.values()]
    vlan20_mac_ip = [(x['link_layer_address'],x['ip']) for x in vlan20_arp.values()]
    print(vlan10_mac_ip)
    print(vlan20_mac_ip)

    # MAC テーブル情報の取得
    devs = service.inventory['sw1'].singleton()
    devs.add(service.inventory['sw2'].singleton())
    res = devs.exec("show mac address-table").wait()
    result = radkit_genie.parse(res, os='iosxe')
    sw1_mac_table = result['sw1']['show mac address-table'].data
    sw2_mac_table = result['sw2']['show mac address-table'].data

    print(sw1_mac_table)
    print(sw2_mac_table)

    vlan10 = [(x['mac_address'], [intf['interface'] for intf in x['interfaces'].values()]) for x in sw1_mac_table['mac_table']['vlans']['10']['mac_addresses'].values()]
    print(vlan10)
    vlan20 = [(x['mac_address'], [intf['interface'] for intf in x['interfaces'].values()]) for x in sw2_mac_table['mac_table']['vlans']['20']['mac_addresses'].values()]
    print(vlan20)

    # MAC アドレスをキーとして、2つの情報を結合する
    sw1 = []
    for mac in vlan10:
        for arp in vlan10_mac_ip:
            if mac[0] == arp[0]:
                sw_mac_ip = ('sw1', mac[0], ",".join(mac[1]), arp[1])
                sw1.append(sw_mac_ip)
    sw2 = []
    for mac in vlan20:
        for arp in vlan20_mac_ip:
            if mac[0] == arp[0]:
                sw_mac_ip = ('sw2', mac[0], ",".join(mac[1]), arp[1])
                sw2.append(sw_mac_ip)

    # 表示
    for x in sw1+sw2:
        print(x)
```

# 免責事項
本サイトおよび対応するコメントにおいて表明される意見は、投稿者本人の個人的意見であり、私の所属する組織の意見ではありません。本サイトの内容は、情報の提供のみを目的として掲載されており、私の所属する組織や他の関係者による推奨や表明を目的としたものではありません。各利用者は、本Webサイトへの掲載により、投稿、リンクその他の方法でアップロードした全ての情報の内容に対して全責任を負い、本Webサイトの利用に関するあらゆる責任から私の所属する組織を免責することに同意したものとします。