ネットワーク機器用のVS CodeのExtensionを作りたい　第一回：コマンドの一覧取得

プログラミング言語はオシャレに書けるのになぜネットワーク機器のコンフィグはメモ帳なんだ。
そうだ、VS Codeの拡張機能を作ろう。

#モチベーション
- メモ帳でコンフィグ書きたくない
- 目視のlintやsyntax highlight やめたい
- 実機を使わずにコマンドを補完したい

#流れ
ざっとした流れはこんな感じです
1. 構文木っぽいものをつくる
2. Syntax Highlightをつくる
3. Diagnosticをつくる(エラー表示)
4. Completionをつくる（補完）

今回は項番1の__構文木__を作ります。

#1.構文木をつくる

構文解析を行うにあたり、まず、機器のコマンドの一覧が必要です。今回はそれを構文木という形で用意します。
構文木はよく知らないのですが、このケースにおいては、大雑把に言うと__入力可能なコマンドが親子関係になっているもの__(と幾つかの情報)だと勝手に思ってます。
CLIだとハテナマークで入力可能なコマンドの一覧が出るので、それを収集・探索していきましょう。

今回は水色のスイッチのconfigureモード用にコードを書きました。
netmikoとかを使うともっとキレイに書けるかもしれません。
もちろん、configureモード以外にもIFモードなど色々なモードがあります。
作りたい機器のリファレンスを参照し、必要なモードのデータを取得しましょう。


下記コードを実行すると、こんな感じでログが出力されます。なんとなく趣があります。
![te.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154094/5f1f3905-1224-718d-e802-e529e23bdfcd.gif)




```python:test.py
from datetime import datetime

import telnetlib
import re
from pprint import pprint
import types
import typing
import json

telnet = telnetlib.Telnet("10.0.0.1")

interactions = {
    'login:': "admin\n",
    '>': "enable\n",
    '#': "conf t\n",
    '(config)#': "terminal length 0\n",
}

for key, val in interactions.items():
    telnet.read_until(key.encode("ascii"))
    telnet.write(val.encode("ascii"))

print(telnet.read_until(b"(config)#").decode("ascii"))

suffix = " ?"
current_mode = "config)#"
empty_ptn = re.compile("^\\s*(config\\)#)?$", re.MULTILINE)
# キャリッジリターン　ここで終端可能なコマンド
cr_ptn = re.compile("^\\x20{2}<cr>\\x20*", re.MULTILINE)
# 普通のコマンド
normal_ptn = re.compile("^\\x20{2}(\\S+)?\\x20+([^\\r\\n]+)", re.MULTILINE)
# TODO 多分必要ない
first_line = re.compile(".*$", re.MULTILINE)

# ポート番号やLAG-IDなどの、表示内容と実際に入力する内容が違うケース
variable_patterns = [
    {
        "pattern": re.compile("(PORT|MLAG)(NO|RANGE(?:<(\\d+)-(\\d+)>)?)"),
        "dummy": "1/1"
    }, {
        "pattern": re.compile("(LAG|VLAN|LIST)?(NO|(?:RANGE)?<(\\d+)-(\\d+)>)"),
        "dummy": lambda matched: matched.group(3) if matched.group(3) is not None else "1"
    }, {
        "pattern": re.compile("(?:X:X::X:X|A\\.B\\.C\\.D)(?:/M)?"),
        "dummy": lambda matched: re.sub("[A-DMX]", "1", matched.group(0))
    }, {
        "pattern": "OFFSET",
        "dummy": "+00:00:00"
    }, {
        "pattern": "YYYYMMDD",
        "dummy": "20190101"
    },  {
        "pattern": "SECOND",
        "dummy": "0"
    }, {
        "pattern": "OID",
        "dummy": "0.1"
    }, {
        "pattern": "MASK",
        "dummy": "1"
    }, {
        "pattern": "HH:MM:SS",
        "dummy": "00:00:00"
    }
]

result_txt = ""

f = open('telnetlog_{}.txt'.format(datetime.now().strftime("%m_%d_%H_%M")), 'a')


# 再帰的にコマンドを打つ
def recursive_survey(command="", index=0, entire=""):
    # 深さ制限
    if index > 20:
        print("index is outbound")
        return
    terminal_tree = {}
    # (command)#から右 使わないので捨てる
    eager = telnet.read_very_eager().decode("ascii")
    if eager:
        f.write(eager)
    # コマンドとクエスチョンマークを送信
    telnet.write((command + suffix).encode("ascii"))
    # (command)#まで読み込む
    # 送信したコマンド+クエスチョンマーク→実行可能なコマンド→(command)#の順番になるので
    f.write(str(telnet.read_until("?\r\n".encode("ascii"), timeout=10), "ascii"))
    raw_result = str(telnet.read_until(current_mode.encode("ascii"), timeout=10), "ascii")
    f.write(raw_result)

    # 現在地点から使用可能なコマンド
    branches = []
    for line in raw_result.split("\n"):
        if empty_ptn.search(line) is not None or current_mode in line:
            continue
        elif "Unrecognized" in line:
            print(line)
        elif cr_ptn.search(line) is not None:
            terminal_tree["CR"] = True
        elif normal_ptn.search(line) is not None:
            arg = normal_ptn.search(line)
            matched = arg.group(1)
            description = arg.group(2)

            # たまに説明が2行に渡るコマンドがあるので　それ用
            if matched is None:
                print("continuation: " + arg.group(0))
                continue

            # sendは送信するコマンド
            branches.append({
                "view": matched,
                "send": matched,
            })
            for pattern in variable_patterns:
                sub_matched = None
                if type(pattern["pattern"]) is str and pattern["pattern"] == matched:
                    sub_matched = matched
                elif isinstance(pattern["pattern"], typing.Pattern):
                    sub_matched = pattern["pattern"].search(matched)

                if sub_matched is None:
                    continue

                if isinstance(pattern["dummy"], str):
                    branches[-1]["send"] = pattern["dummy"]
                    branches[-1]["send"] = pattern["dummy"]
                elif isinstance(pattern["dummy"], types.LambdaType):
                    branches[-1]["send"] = pattern["dummy"](sub_matched)
                break
        else:
            print("Not Found : " + line)

    # 現在地点から実行可能なコマンドを探査していく
    for branch in branches:
        branch_send_command = branch["send"]
        # vbモードは探査しきれないので
        # LINEは無限になってしまうので(スペースを許容する文字列 descriptionなど)
        if branch_send_command == "vb" or branch_send_command == "LINE" and command == "LINE":
            continue
        print("\t" * index + "-> " + branch_send_command)
        terminal_tree[branch["view"]] = recursive_survey(branch["send"], index + 1, entire + " " + branch["send"])
        print("\t" * index + "<- " + branch_send_command)
        # 打ったコマンドの文字数とスペースの数だけ文字消去(制御文字)を送信
        telnet.write(telnetlib.NAOL * (len(branch_send_command) + 1))
    return terminal_tree


result_tree = recursive_survey()
pprint(result_tree)
f.close()

f = open('commands_{}.json'.format(datetime.now().strftime("%m_%d_%H_%M")), 'a')
f.write(json.dumps(result_tree))
f.close()



```

実行結果はこんな感じです。
中古で買った機器とはいえ、権利関係上載せづらいので、イメージです。
実際には、configモードのみで7,500行程度、容量は250kB程度。
全モード合計で1.3MB程度でした。

```python:実行結果.py
#権利上載せられない気がするのでイメージです
{'interface':{'port':...},{'vlan':...}}
```

#2.モード遷移を補足する
ネットワーク機器には、特権モード、設定モードなど様々なモードがあります。
それを移動できるように、書き出されたJSONの
`enable`　`conf t`　`exit`　`interface fastethernet 1`などにモード遷移情報を追加しましょう。
そういったコマンドの一覧は、リファレンスに遷移図とともに載っていたりしますので
各自で対象機器のリファレンスを読んでみてください。

今回は、対象コマンドのキーにintoを追加し、その値として遷移先モードの名前を書くようにしてみました。

```json
"enable": {
  "into": "privilege",
  ...
}
```

#参考にさせていただいたページ
[設定ファイルエディターをつくる方へ - LGTM](https://codeout.hatenablog.com/entry/2019/01/27/153719)　を参考にさせていただきました。


#つづく
今日の日曜大工はこれでおしまいです。
また進捗があったら続きを書きます。