これは Clojure Advent Calendar 2018 6日目の記事です。
こんにちは。インフラエンジニアの @notake です。
ヤマハルーターRTX1210に接続されているDHCPクライアントの一覧が必要になったため、リース情報を吸い出すためにClojureを活用した話を書きたいと思います。
記号
本記事では複数種類のターミナルを扱いますので、区別できるよう記号を定義します。
-
$
: LinuxまたはmacOSのターミナルに入力するコマンドです。 -
>
: ヤマハルーターの通常ユーザーで入力するコマンドです。 -
#
: ヤマハルーターの管理者権限で入力するコマンドです。 -
user=>
: ClojureのREPLです。
ヤマハルーターにSSHする
ルーターから情報を吸い出すには、当然ながら何かしらの手段でルーターに接続する必要があります。
ヤマハルーターに接続するには、シリアルポートを使用するか、LANケーブルを繋いでtelnetで繋ぐ方法があります。
しかし、シリアルポートはルーターとコンピュータが1対1でしか接続できないため、情報取得の度にコンピュータと専用のケーブルで接続する必要があります。一方のtelnetは既存のネットワークをそのまま利用可能ですが、ネットワーク上を平文が飛び交うため、あまりセキュアとは言えません。
そこで、ヤマハルーターに接続するもう一つの手段、SSHを使用します。
公式ドキュメントによると、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 に書かれていますが、SSHには、shell
チャンネルと exec
チャンネルが存在します。
shell
チャンネルを使用すると、リモートに対して対話シェルの起動を要求します。
exec
チャンネルを使用した場合は、リモートに対してコマンドの実行を要求します。一般的にはコマンドごとにリモートでシェルが起動し、コマンド終了と共にシェルも閉じられます。
エラーメッセージ Channel request 'exec' is not supported
から察するに、ヤマハルーターのSSHサーバー機能では恐らく shell
チャンネルのみがサポートされ、exec
チャンネルは対応されていなかった、という事が考えられるでしょう。
ClojureからヤマハルーターにSSH
前置きが長かったですが、いよいよ本題です。
例として、Leiningenで rtx-dhcp
プロジェクトを作成します。
$ lein new rtx-dhcp
ClojureからヤマハルーターにSSH接続を行うには、JSch というJava用SSHライブラリを使用します1。
:dependencies
に com.jcraft/jsch
を追加します。
(defproject rtx-dhcp "0.1.0-SNAPSHOT"
:dependencies [[org.clojure/clojure "1.8.0"]
[com.jcraft/jsch "0.1.54"]])
こちらが、実際にSSH接続を担う処理です。doto
マクロである程度纏められるのが便利です。
(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の大変気に入っている部分です。
(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リース情報を吸い出すことができます。
$ lein repl
user=> (use 'rtx-dhcp.core)
nil
user=> (-> (dhcp-clients)
#_=> clojure.pprint/pprint)
({"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のキーワードにして、お洒落な出力にしましょう。
(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)
({: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 を使用したJSON出力:
user=> (require 'clojure.data.json)
nil
user=> (-> (dhcp-clients)
#_=> rename-all-keys
#_=> clojure.data.json/pprint)
[{"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 を使用したYAML出力:
user=> (require 'yaml.core)
nil
user=> (-> (dhcp-clients)
#_=> rename-all-keys
#_=> (yaml.core/generate-string :dumper-options {:flow-style :block})
#_=> print)
- 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の解説 です。