Clojureとヤマハルーター

これは [Clojure Advent Calendar 2018](https://qiita.com/advent-calendar/2018/clojure) 6日目の記事です。

こんにちは。インフラエンジニアの @notake です。

ヤマハルーターRTX1210に接続されているDHCPクライアントの一覧が必要になったため、リース情報を吸い出すためにClojureを活用した話を書きたいと思います。

# 記号

本記事では複数種類のターミナルを扱いますので、区別できるよう記号を定義します。

* `$`: LinuxまたはmacOSのターミナルに入力するコマンドです。
* `>`: ヤマハルーターの通常ユーザーで入力するコマンドです。
* `#`: ヤマハルーターの管理者権限で入力するコマンドです。
* `user=>`: ClojureのREPLです。

# ヤマハルーターにSSHする

ルーターから情報を吸い出すには、当然ながら何かしらの手段でルーターに接続する必要があります。

ヤマハルーターに接続するには、シリアルポートを使用するか、LANケーブルを繋いでtelnetで繋ぐ方法があります。
しかし、シリアルポートはルーターとコンピュータが1対1でしか接続できないため、情報取得の度にコンピュータと専用のケーブルで接続する必要があります。一方のtelnetは既存のネットワークをそのまま利用可能ですが、ネットワーク上を平文が飛び交うため、あまりセキュアとは言えません。

そこで、ヤマハルーターに接続するもう一つの手段、SSHを使用します。

[公式ドキュメント](http://www.rtpro.yamaha.co.jp/RT/docs/sshd/index.html)によると、SSHでのログインを有効にするには、初回のみコンソールケーブルによるコマンド入力か、またはWeb GUIによるSSHの有効化が必要です。

```
# login user USERNAME PASSWORD
# sshd host key generate
# sshd host lan1
# sshd service on
```

ルーターのIPアドレスが `192.168.0.1` の場合、次のコマンドでルーターにログインできます。

```
$ ssh USERNAME@192.168.0.1
```

# DHCP情報の取得

ヤマハルーターのDHCPリース情報は、ルーターにSSH接続後、対話シェルにて `show status dhcp` を実行すると取得が可能です。

```
$ ssh USERNAME@192.168.0.1
```

```
> show status dhcp

DHCP Scope number: 1
      Network address: 192.168.0.0
          Leased address: 192.168.0.2
        (type) Client ID: (ff) 01 23 45 67 89 ab cd ef 01 23 45 67 89 ab cd ef 
01 23
               Host Name: LinuxLaptop
         Remaining lease: 2days 23hours 26min. 16secs.
          Leased address: 192.168.0.3
        (type) Client ID: (01) 01 23 45 67 89 ab
               Host Name: Workstation
         Remaining lease: 2days 23hours 34min. 9secs.
          Leased address: 192.168.0.4
        (type) Client ID: (01) cd ef 01 23 45 67
               Host Name: Gaming
         Remaining lease: 2days 23hours 59min. 33secs.
                  All: 190
               Except: 0
               Leased: 3
               Usable: 187
```

ところで、`ssh` コマンドには、引数にコマンドを続けると、リモート上で直接コマンドを実行する機能があります。

```
$ ssh USERNAME@192.168.0.1 show status dhcp
```

しかし、ヤマハルーターから直接DHCPリースを得ようとして、上記のコマンドを実行しても失敗します。

```
Received disconnect from 192.168.0.1 port 22:2: Channel request 'exec' is not supported
Disconnected from 192.168.0.1 port 22
```

# SSHのチャンネル

なぜヤマハルーターにSSH接続する場合、`ssh` によって開いた対話シェル上ではコマンドが実行できるにも関わらず、`ssh` に引数としてコマンドを渡しても失敗するのでしょうか。

詳しくは [RFC 4254](https://tools.ietf.org/html/rfc4254#section-6.5) に書かれていますが、SSHには、`shell` チャンネルと `exec` チャンネルが存在します。

`shell` チャンネルを使用すると、リモートに対して対話シェルの起動を要求します。
`exec` チャンネルを使用した場合は、リモートに対してコマンドの実行を要求します。一般的にはコマンドごとにリモートでシェルが起動し、コマンド終了と共にシェルも閉じられます。

エラーメッセージ `Channel request 'exec' is not supported` から察するに、ヤマハルーターのSSHサーバー機能では恐らく `shell` チャンネルのみがサポートされ、`exec` チャンネルは対応されていなかった、という事が考えられるでしょう。

# ClojureからヤマハルーターにSSH

前置きが長かったですが、いよいよ本題です。

例として、Leiningenで `rtx-dhcp` プロジェクトを作成します。

```shell
$ lein new rtx-dhcp
```

ClojureからヤマハルーターにSSH接続を行うには、[JSch](http://www.jcraft.com/jsch/) というJava用SSHライブラリを使用します[^1]。
`:dependencies` に `com.jcraft/jsch` を追加します。

[^1]: 最初は [clj-ssh](https://github.com/hugoduncan/clj-ssh) を使おうとしていたのですが、`shell` チャンネル特有の挙動に上手く対応できませんでした。

```clojure:project.clj
(defproject rtx-dhcp "0.1.0-SNAPSHOT"
  :dependencies [[org.clojure/clojure "1.8.0"]
                 [com.jcraft/jsch "0.1.54"]])
```

こちらが、実際にSSH接続を担う処理です。`doto` マクロである程度纏められるのが便利です。

```clojure:src/rtx_dhcp/ssh.clj
(ns rtx-dhcp.ssh
  (:import [java.io ByteArrayOutputStream PipedInputStream PipedOutputStream]
           [java.nio.charset StandardCharsets]
           [com.jcraft.jsch JSch]))

(def charset StandardCharsets/UTF_8)

(defn yamaha-ssh
  [command params & {:keys [sleep timeout] :or {sleep 2500 timeout 10000}}]
  (let [{:keys [user host port password]} params
        session (.getSession (JSch.) user host port)]
    (try
      (do
        (doto session
          (.setConfig "StrictHostKeyChecking" "no")
          (.setPassword password)
          (.connect timeout))
        (let [channel (.openChannel session "shell")]
          (try
            (with-open [in (PipedInputStream.)
                        pin (PipedOutputStream. in)
                        out (ByteArrayOutputStream.)]
              (doto channel
                (.setInputStream in)
                (.setOutputStream out)
                (.connect timeout))
              (Thread/sleep sleep)
              (.write pin (.getBytes (str command "\n") charset))
              (Thread/sleep sleep)
              (.flush out)
              (String. (.toByteArray out) charset))
            (finally
              (when channel (.disconnect channel))))))
      (finally
        (when session (.disconnect session))))))
```


前述の通り、ヤマハルーターは `exec` チャンネルをサポートしていません。そのため、JSchを使用して `shell` チャンネルを開き、チャンネルに対して実行したいコマンドを `PipedInputStream` 経由で流し込む、という実装になっています。

また、実際にシェルコマンドを実行する前後に `Thread/sleep` を挟んでいます。
`shell` チャンネルでは標準入出力を直接 `InputStream` / `OutputStream` で読み書きするため、ルーターへのログイン完了やコマンドの実行終了を待たずに処理が進んでしまい、実行結果を取得できない場合があるという事態に陥ったためです。

# 吸い出した情報をパースする

ヤマハルーターから情報を1回で吸い出すために、予めヤマハルーター上で次のコマンドを実行しておいて下さい。

```
# console lines infinity
```

なお、以降のパーサーにおいて、ヤマハルーターのコンソール言語は英語を前提としています。

```
# console character en.ascii
```

あとは、吸い出した文字列をClojureのデータ構造に落とし込むべく、一気にパースしていきます。

柔軟なシーケンス加工関数群とJava譲りの豊富な文字列加工処理、そしてこれらの関数を一気に貫く Threading (`->>`) マクロにより、どんな条件のパーサーでもすっきりと書けてしまうのが、Clojureの大変気に入っている部分です。

```clojure:src/rtx_dhcp/core.clj
(ns rtx-dhcp.core
  (:require [clojure.string :as string]
            [rtx-dhcp.ssh :refer [yamaha-ssh]]))

(def ^:private ssh-config {:user "USERNAME" :host "192.168.0.1" :port 22 :password "PASSWORD"})

(defn dhcp-clients
  []
  (->> (yamaha-ssh "show status dhcp" ssh-config)
       string/split-lines
       (map string/trim)
       (drop-while #(not (.contains % "Leased address:")))
       (take-while #(not (.contains % "All:")))
       (partition-by #(.contains % "Leased address:"))
       (partition-all 2)
       (map (partial apply concat))
       (map (fn [itm]
              (->> itm
                   ;; Host名が空の場合は削除
                   (remove #(= % "Host Name:"))
                   ;; ClientIDが長い場合に2行に折り返す場合があるため、1行にまとめる
                   (reduce #(if (.contains %2 ": ")
                              (conj %1 %2)
                              (conj (vec (drop-last %1)) (str (last %1) " " %2)))
                           [])
                   (mapcat #(string/split % #": "))
                   (apply array-map))))))
```

REPLから `(dhcp-clients)` を実行すると、ヤマハルーターからDHCPリース情報を吸い出すことができます。

```shell
$ lein repl
```

```
user=> (use 'rtx-dhcp.core)
nil
user=> (-> (dhcp-clients)
  #_=>     clojure.pprint/pprint)
```

```clojure
({"Leased address" "192.168.0.2",
  "(type) Client ID"
  "(ff) 01 23 45 67 89 ab cd ef 01 23 45 67 89 ab cd ef 01 23",
  "Host Name" "LinuxLaptop",
  "Remaining lease" "2days 23hours 9min. 22secs."}
 {"Leased address" "192.168.0.3",
  "(type) Client ID" "(01) 01 23 45 67 89 ab",
  "Host Name" "Workstation",
  "Remaining lease" "2days 23hours 17min. 15secs."}
 {"Leased address" "192.168.0.4",
  "(type) Client ID" "(01) cd ef 01 23 45 67",
  "Host Name" "Gaming",
  "Remaining lease" "2days 23hours 42min. 39secs."})
```

折角なのでHashMapのキーをClojureのキーワードにして、お洒落な出力にしましょう。

```clojure
(def new-keywords {"Leased address" :leased-address
                   "(type) Client ID" :client-id
                   "Host Name" :host-name
                   "Remaining lease" :remaining-lease})

(defn rename-all-keys [v]
  (map #(clojure.set/rename-keys % new-keywords) v))
```

```
user=> (-> (dhcp-clients)
  #_=>     rename-all-keys
  #_=>     clojure.pprint/pprint)
```

```clojure
({:leased-address "192.168.0.2",
  :client-id
  "(ff) 01 23 45 67 89 ab cd ef 01 23 45 67 89 ab cd ef 01 23",
  :host-name "LinuxLaptop",
  :remaining-lease "2days 23hours 9min. 22secs."}
 {:leased-address "192.168.0.3",
  :client-id "(01) 01 23 45 67 89 ab",
  :host-name "Workstation",
  :remaining-lease "2days 23hours 17min. 15secs."}
 {:leased-address "192.168.0.4",
  :client-id "(01) cd ef 01 23 45 67",
  :host-name "Gaming",
  :remaining-lease "2days 23hours 42min. 39secs."})
```

一度Clojureのデータ構造になってしまえば、あとはJSONやYAMLなど様々なデータ構造に変換できますね。この辺りの柔軟さも好きなポイントです。

[clojure/data.json](https://github.com/clojure/data.json) を使用したJSON出力:

```
user=> (require 'clojure.data.json)
nil
user=> (-> (dhcp-clients)
  #_=>     rename-all-keys
  #_=>     clojure.data.json/pprint)
```

```json
[{"leased-address":"192.168.0.2",
  "client-id": 
  "(ff) 01 23 45 67 89 ab cd ef 01 23 45 67 89 ab cd ef 01 23",
  "host-name":"LinuxLaptop",
  "remaining-lease":"2days 23hours 9min. 22secs."},
 {"leased-address":"192.168.0.3",
  "client-id":"(01) 01 23 45 67 89 ab",
  "host-name":"Workstation",
  "remaining-lease":"2days 23hours 17min. 15secs."},
 {"leased-address":"192.168.0.4",
  "client-id":"(01) cd ef 01 23 45 67",
  "host-name":"Gaming",
  "remaining-lease":"2days 23hours 42min. 39secs."}]
```

[owainlewis/yaml](https://github.com/owainlewis/yaml) を使用したYAML出力:

```
user=> (require 'yaml.core)
nil
user=> (-> (dhcp-clients)
  #_=>     rename-all-keys
  #_=>     (yaml.core/generate-string :dumper-options {:flow-style :block})
  #_=>     print)
```

```yaml
- leased-address: 192.168.0.2
  client-id: (ff) 01 23 45 67 89 ab cd ef 01 23 45 67 89 ab cd ef 01 23
  host-name: LinuxLaptop
  remaining-lease: 2days 23hours 9min. 22secs.
- leased-address: 192.168.0.3
  client-id: (01) 01 23 45 67 89 ab
  host-name: Workstation
  remaining-lease: 2days 23hours 17min. 15secs.
- leased-address: 192.168.0.4
  client-id: (01) cd ef 01 23 45 67
  host-name: Gaming
  remaining-lease: 2days 23hours 42min. 39secs.
```

## インフラ用途にClojure

今回はヤマハルーターからデータを吸い上げるのが目的でしたが、SSH部分の処理を改善すれば、ヤマハルーターの構成管理をClojureから行ったり、ルーターのファイアウォールをednで定義できるかも…などなど夢が広がります。

スタイリッシュな関数型言語でありながら、Javaのライブラリを直接叩いて泥臭い?処理もこなせるのが魅力のClojure、これからも様々な用途に使いこなしていきたいものです。

明日は @lagenorhynque さんの Ductの解説 です。