ObservableSSH GitHub 公開しました

名称はめだまライブラリなんですけどね

 とりあえずまだまだ課題は残っているのですが(と、あらかじめ予防線をはりつつ(ぉぃ))、目玉ライブラリ(Medama) という名称で GitHub で ObservableSSH を公開しました。 目玉ライブラリという名称なので、さりげなく ObservableSSH 以外の機能も入ってます。とりあえず概要から導入まで含めてご紹介させていただければと思います。

概要

 drieseng さんと olegkap さんのC#によるSSH実装であるSSH.NETと、neuecc さんの Unity用に実装されたReactive Extensions(GitHub)(アセットストア)を使用して、非同期なSSH通信をサポートします。単発コマンドだと割と簡単に使えますが、非同期なので tail -f /var/log/php.log 的なサーバ監視アプリとしても使えそうです。

 具体的な動作としては、目的のサーバにSSHで接続したあと、標準出力を Observable とみなして1行づつイベントとして受け取れるようになります。欠点としては、改行コードが出力されるまでイベントがやってきません。その場合は、標準出力が落ち着く度に EndOfStream なイベントがやってくるので、その際に読み込みバッファの末尾の文字列をチェックする事ができます。

 もすこし噛み砕いて説明すると、コマンド入力待ちの行は改行コードが無いため、Unity の標準出力イベントとして落ちてきません。そこで、EndOfStream イベントが来た時に、読み込みバッファの末尾が「]$ 」(角カッコ閉じ、ドル、半角空白)に一致すると、コマンド入力待ちとみなす。という風に使うわけです。具体的なコードについては後述します。

 又、拡張しやすいFSM : 『Arbor 2』を使用して、ビジュアルシェルスクリプトもどきのクラスも作ってみました。こっちは1ノード1コマンドだと少し効率が悪いのでシェルスクリプト毎にノードを用意できるクラスが必要かなと。あとは、サーバ接続が切れた時の再接続・復帰ができたらいいですね。さらに、異常を検知したら処理を中断してアラートをあげてくれると最高かも。

 その他の機能としては、サンプル表示用に uGUI を XML から自動生成する機能もあります。HTML 的に書けたらいいなーと思ってがんばって実装中です。 こちらは色々最適化や不要メソッドの削除は進行中なので、大幅に仕様が変わる可能性があります。

機能一覧

  • XMLからuGUI自動生成(ヒエラルキにCanvasとEventSystemの自動生成もやります)
  • スレッド型ObservableSSH
  • マイクロコルーチン型ObservableSSH
  • Arbor2によるObservableSSH制御

インストールの大まかな概要

  • Unity5.6を使用します
  • TortoiseGitを使用します
  • VisualStudio2017を使用します
  • 目玉ライブラリをGitHubからCloneします
  • SSH.NETをGitHubからCloneしてVisualStudio2017でDLLをビルドしてUnityプロジェクトに突っ込みます
  • UniRxをアセットストアからダウンロードしてインポートします
  • Arbor2をアセットストアからダウンロードしてインポートします(オプション)

インストール

目玉ライブラリをGitHubからClone

ここでは E:\temp 以下に Clone するという前提で進めます。ファイルエクスプローラの何も無い空間を右クリックして GitClone をクリック

URL に https://github.com/medamap/Medama.git と入力し、OK をクリック

Clone が終わったら OK をクリック

Unity5 を起動して、Open をクリック

先ほど Clone したフォルダを選び、フォルダの選択をクリック

UniRxのインポート

UnityのWindowメニューをクリックし、Asset Storeをクリック

Asset Store が開くので、検索キーワード入力欄に UniRx を入力して Enter キーを押下する

検索結果のちょっと下の方に表示されているので、少し下にスクロールさせて UniRx を見つけたらクリック

UniRx をダウンロードしてインポートしましょう(この例はすでに何度も取得してるので最初からインポートと表示されています)

Importをクリック

Arbor2のインポート

Arbor2対応はこれから充実させていく予定なのですが、このアセットをインポートしない場合は目玉ライブラリの Assets\Plugins\Medama\Scripts\ArborScripts を削除する事でエラーが出なくなります。(アセットの存在を自動検知で条件コンパイルする機能があるといいなぁ)

Asset Store のホームボタンをクリック

検索キーワード入力欄に Arbor2 を入力して Enter キーを押下する

検索結果のちょっと下の方に表示されているので、少し下にスクロールさせて UniRx を見つけたらクリック

Arbor2 をダウンロードしてインポートしましょう(この例はすでに何度も取得してるので最初からインポートと表示されています)

Importをクリック

SSH.NETをGitHubからCloneしてDLLビルドしてインポート

再び E:\temp をファイルエクスプローラで開き、何も無いところを右クリックして Git Clone をクリック

URL に https://github.com/sshnet/SSH.NET.git を入力し、OK をクリック

Clone が完了したら SSH.NET\src に移動し、Renci.SshNet.VS2017.sln をダブルクリックして VisualStudio2017 を起動する

VisualStudio2017 で無事開けたら、ソリューションエクスプローラーの Renci.SshNet.NET35 を選択し、Release ビルドを選択する

ソリューションエクスプローラーの Renci.SshNet.NET35 を右クリックし、ビルドをクリック

Unity のプロジェクトビューの Plugins を右クリックし、Create → Folder をクリック

新規フォルダ名を Renci.SshNet にする

先ほど VisualStudio2017 でビルドした DLL が E:\temp\SSH.NET\src\Renci.SshNet.NET35\bin\Release にできているので、Renci.SshNet.dll を Unity のプロジェクトビュー Plugins/Renci.SshNet にドロップしてインポートする

Unity プロジェクトビューの Plugins/Renci.SshNet フォルダの左隣の黒三角をクリックしてツリーを展開し、Plugins/Renci.SshNet/Renci.SshNet を選択し、インスペクタービューの設定を図の通り(Any Platform をチェック状態、Exclude Platforms のチェックを全て外す)にして Apply をクリック

以上で、とりあえず使える状態にもっていけます。

サンプルシーン

あらかじめ、Assets\Plugins\Medama\Examples にいくつかサンプルシーンを入れてますので、適当に動かしてみてください。ちなみに、SSH 接続用のサーバは自前でご用意くださいませ。あ、秘密鍵ログインにまだ対応してなかった。とりあえず、ローカル環境のサーバ向けということで(ぉぃ)

使い方の例

uGUI自動生成

 XMLからuGUIを自動生成します。自動生成されたuGUIは純粋なuGUIで、継承したカスタムとかじゃないのでここからさらに独自機能とかつけやすいかもしれません(かもしれません)

 XMLの文字列をパースしてUIツリーを作成し、Canvas を自動作成(すでに存在してたらそれを採用)して UI をぶらさげます。 戻り値は Dictionary を返します。キーがインスタンスID になります。こいつを使って生成した UI のコンポーネントを取得できますが、親子関係が全部フラットになってるので、親子関係を元にコンポーネントを取得したい場合はLINQ to GameObjectを使用するのが良いです。

using System.Linq;
using UnityEngine;
using UnityEngine.UI;
using Medama.EUGML;

public class eugml : MonoBehaviour {
    void Start () {
        // Create UI from XML.
        var dc = gameObject.MedamaUIParseXml(@"<?xml version='1.0'?>
<uGUI xmlins='http://megamin.jp/ns/unity3d/ugui/eugml'>
  
  <!-- Window form -->
  <AddNode
    name='FormMain'
    sprite='resources://Medama/EUGML/UI001#Window001'
    layout='StretchStretch' top='8' bottom='8' left='8' right='8'>

    <!-- Title bar -->
    <AddNode
      name='TitleMain' sprite='resources://Medama/EUGML/UI001#Header001'
      layout='TopStretch' height='24' top='8' left='8' right='8' spritetype='Simple'>
      <AddNode name='Text' layout='StretchStretch'>
        <SetText textstring=' [__] TEST SSH LOGIN' color='white' alignment='MiddleLeft' />
      </AddNode>
    </AddNode>
    
    <!-- Contents -->
    <AddNode
      name='GroupMain' sprite='resources://Medama/EUGML/UI001#Group001'
      top='40' bottom='8' left='8' right='8'>
      
      <!-- Input host address -->
      <AddNode
        name='TextLabelHost' layout='TopLeft' width='100' height='30' top='8' left='8'>
        <SetText textstring='HOST : ' alignment='MiddleLeft' />
      </AddNode>
      <AddNode
        name='InputHost' sprite='resources://Medama/EUGML/UI001#Text001'
        layout='TopLeft' width='160' height='30' top='8' left='108'>
        <SetInputField />
      </AddNode>
      
      <!-- Input user name -->
      <AddNode
        name='TextLabelUser' layout='TopLeft' width='100' height='30' top='48' left='8'>
        <SetText textstring='USER : ' alignment='MiddleLeft' />
      </AddNode>
      <AddNode
        name='InputUser' sprite='resources://Medama/EUGML/UI001#Text001'
        layout='TopLeft' width='160' height='30' top='48' left='108'>
        <SetInputField />
      </AddNode>
      
      <!-- Input password -->
      <AddNode
        name='TextLabelPassword' layout='TopLeft' width='100' height='30' top='88' left='8'>
        <SetText textstring='PASSWORD : ' alignment='MiddleLeft' />
      </AddNode>
      <AddNode
        name='InputPassword' sprite='resources://Medama/EUGML/UI001#Text001'
        layout='TopLeft' width='160' height='30' top='88' left='108'>
        <SetInputField />
      </AddNode>
      
      <!-- Submit button -->
      <AddNode
        name='ButtonLogin' sprite='resources://Medama/EUGML/UI001#Button003'
        layout='TopLeft' width='160' height='30' top='128' left='8'>
        <SetButton textButton='LOGIN' />
      </AddNode>
    </AddNode>
  </AddNode>
</uGUI>");

        // Get UI components.
        // * Caution: Null not checking *
        var inputHost = dc
            .Where(gopair => gopair.Value.name == "InputHost")
            .First()
            .Value
            .GetComponent<InputField>();

        var inputUser = dc
            .Where(gopair => gopair.Value.name == "InputUser")
            .First()
            .Value
            .GetComponent<InputField>();

        var inputPassword = dc
            .Where(gopair => gopair.Value.name == "InputPassword")
            .First()
            .Value
            .GetComponent<InputField>();

        var buttonLogin = dc
            .Where(gopair => gopair.Value.name == "ButtonLogin")
            .First()
            .Value
            .GetComponent<Button>();
    }
}

スレッド版ObservableSSH

 次はObservableSSHの使い方です。サーバに接続するので、接続先情報を入力するための UI を表示させます。先ほどのサンプルは XML をプログラムの中に埋め込んでいて非常によろしくないので、 Resources に配置して XML を読み込む形式にします。 Assets\Plugins\Medama\Resources\Medama\EUGML\login.xml を用意します。

<?xml version='1.0'?>
<uGUI xmlins='http://megamin.jp/ns/unity3d/ugui/eugml'>
  
  <!-- Window form -->
  <AddNode
    name='FormMain'
    sprite='resources://Medama/EUGML/UI001#Window001'
    layout='StretchStretch' top='8' bottom='8' left='8' right='8'>

    <!-- Title bar -->
    <AddNode
      name='TitleMain' sprite='resources://Medama/EUGML/UI001#Header001'
      layout='TopStretch' height='24' top='8' left='8' right='8' spritetype='Simple'>
      <AddNode name='Text' layout='StretchStretch'>
        <SetText textstring=' [__] TEST SSH LOGIN' color='white' alignment='MiddleLeft' />
      </AddNode>
    </AddNode>
    
    <!-- Contents -->
    <AddNode
      name='GroupMain' sprite='resources://Medama/EUGML/UI001#Group001'
      top='40' bottom='8' left='8' right='8'>
      
      <!-- Input host address -->
      <AddNode
        name='TextLabelHost' layout='TopLeft' width='100' height='30' top='8' left='8'>
        <SetText textstring='HOST : ' alignment='MiddleLeft' />
      </AddNode>
      <AddNode
        name='InputHost' sprite='resources://Medama/EUGML/UI001#Text001'
        layout='TopLeft' width='160' height='30' top='8' left='108'>
        <SetInputField />
      </AddNode>
      
      <!-- Input user name -->
      <AddNode
        name='TextLabelUser' layout='TopLeft' width='100' height='30' top='48' left='8'>
        <SetText textstring='USER : ' alignment='MiddleLeft' />
      </AddNode>
      <AddNode
        name='InputUser' sprite='resources://Medama/EUGML/UI001#Text001'
        layout='TopLeft' width='160' height='30' top='48' left='108'>
        <SetInputField />
      </AddNode>
      
      <!-- Input password -->
      <AddNode
        name='TextLabelPassword' layout='TopLeft' width='100' height='30' top='88' left='8'>
        <SetText textstring='PASSWORD : ' alignment='MiddleLeft' />
      </AddNode>
      <AddNode
        name='InputPassword' sprite='resources://Medama/EUGML/UI001#Text001'
        layout='TopLeft' width='160' height='30' top='88' left='108'>
        <SetInputField />
      </AddNode>
      
      <!-- Submit button -->
      <AddNode
        name='ButtonLogin' sprite='resources://Medama/EUGML/UI001#Button003'
        layout='TopLeft' width='160' height='30' top='128' left='8'>
        <SetButton textButton='LOGIN' />
      </AddNode>
    </AddNode>
  </AddNode>
</uGUI>

 上記 XML と、今までのインストール作業がうまくいっていれば、下記ソースそのままで動くはずです。(よくある、「ここは基本的な事なので省きます」とか「using は省略しますとか、『ムキー、ここが一番知りたいんじゃ!!』と度々経験するアレを華麗に回避」)

 スレッド版は OnDestroy() の中で明示的に Dispose しないと Unity のプロセスが固まります。ここ改善されるまでは多分使い勝手はマイクロコルーチンの方が良いかも。

using UnityEngine;
using UnityEngine.UI;
using Medama.ObservableSsh;
using Medama.EUGML;
using System.Collections.Generic;
using System.Linq;
using UniRx;

public class command_thread : MonoBehaviour {
    ObservableSSH ssh;
    public Queue<string> commands = new Queue<string>();

    void Start() {
        // Create UI.
        var loginxml = Resources.Load<TextAsset>("Medama/EUGML/login");
        var dc = gameObject.MedamaUIParseXml(loginxml.text);

        // Get UI components.
        // * Caution: Null not checking *
        var inputHost = dc
            .Where(gopair => gopair.Value.name == "InputHost")
            .First()
            .Value
            .GetComponent<InputField>();

        var inputUser = dc
            .Where(gopair => gopair.Value.name == "InputUser")
            .First()
            .Value
            .GetComponent<InputField>();

        var inputPassword = dc
            .Where(gopair => gopair.Value.name == "InputPassword")
            .First()
            .Value
            .GetComponent<InputField>();

        var buttonLogin = dc
            .Where(gopair => gopair.Value.name == "ButtonLogin")
            .First()
            .Value
            .GetComponent<Button>();

        // Regist command queue.
        commands.Enqueue("ps");
        commands.Enqueue("rpm -qa");
        commands.Enqueue("df -h");

        // Button click event.
        buttonLogin
            .OnClickAsObservable()
            .Subscribe(_ => {
                // Initialize SSH.
                ssh = new ObservableSSH(
                    host: inputHost.text,
                    user: inputUser.text,
                    password: inputPassword.text);

                if (ssh.con.IsAuthenticated) {
                    // Monitor stdout.
                    ssh
                        .stdOutSubject
                        .Subscribe(line => Debug.Log(line))
                        .AddTo(ssh.compositeDisposable);
                    // Check stream status and send shell command.
                    ssh
                        .statusSubject
                        .Where(status =>
                            status == ObservableSshStatus.EndOfStream &&
                            ssh.CheckBuffer("]$ ") &&
                            commands.Count > 0)
                        .Subscribe(status => ssh.writeSshSubject.OnNext(commands.Dequeue()));
                }
                // Double login prevention.
                buttonLogin.enabled = false;
            })
            .AddTo(this);
    }

    // The thread version require dispose SSH on destroy.
    private void OnDestroy() {
        if (ssh != null) {
            ssh.Dispose();
        }
    }
}

マイクロコルーチン版ObservableSSH

 内容スレッド版とほとんど同じですが、初期化の部分と OnDestroy() の部分が少し違います。マイクロコルーチン版は MonoBehabiour のクラスの中に ObservableSSH のプロパティを持っており、中身はイベントの処理方法がちょっと違うだけ程度です。

using UnityEngine;
using UnityEngine.UI;
using Medama.ObservableSsh;
using Medama.EUGML;
using System.Collections.Generic;
using System.Linq;
using UniRx;

public class command_microcoroutine : MonoBehaviour {
    ObservableSSHMonoBehaviour ssh;
    public Queue<string> commands = new Queue<string>();

    void Start() {
        // Create UI.
        var loginxml = Resources.Load<TextAsset>("Medama/EUGML/login");
        var dc = gameObject.MedamaUIParseXml(loginxml.text);

        // Get UI components.
        // * Caution: Null not checking *
        var inputHost = dc
            .Where(gopair => gopair.Value.name == "InputHost")
            .First()
            .Value
            .GetComponent<InputField>();

        var inputUser = dc
            .Where(gopair => gopair.Value.name == "InputUser")
            .First()
            .Value
            .GetComponent<InputField>();

        var inputPassword = dc
            .Where(gopair => gopair.Value.name == "InputPassword")
            .First()
            .Value
            .GetComponent<InputField>();

        var buttonLogin = dc
            .Where(gopair => gopair.Value.name == "ButtonLogin")
            .First()
            .Value
            .GetComponent<Button>();

        // Regist command queue.
        commands.Enqueue("ps");
        commands.Enqueue("rpm -qa");
        commands.Enqueue("df -h");

        // Button click event.
        buttonLogin
            .OnClickAsObservable()
            .Subscribe(_ => {
                // Initialize SSH.
                ssh = gameObject.AddComponent<ObservableSSHMonoBehaviour>();
                ssh.InitializeAndStart(
                    host: inputHost.text,
                    user: inputUser.text,
                    password: inputPassword.text);
                
                if (ssh.con.IsAuthenticated) {
                    // Monitor stdout.
                    ssh
                        .stdOutSubject
                        .Subscribe(line => Debug.Log(line))
                        .AddTo(this);
                    // Check stream status and send shell command.
                    ssh
                        .statusSubject
                        .Where(status =>
                            status == ObservableSshStatus.EndOfStream &&
                            ssh.CheckBuffer("]$ ") &&
                            commands.Count > 0)
                        .Subscribe(status => ssh.writeSshSubject.OnNext(commands.Dequeue()));
                }
                // Double login prevention.
                buttonLogin.enabled = false;
            })
            .AddTo(this);
    }

    // The micro coroutine version automatically dispose SSH.
    //private void OnDestroy() {
    //    if (ssh != null) {
    //        ssh.Dispose();
    //    }
    //}

}

ObservableSSHのフィールド

フィールド名 概要
ConnectionInfo   con   接続オブジェクト  
SshClient   sshClient   SSHクライアントオブジェクト  
bool   loop   SSH読み込みストリームを強制終了させたい時はこれを false にする 
ShellStream   shellStream   シェルストリーム。これから read や write のストリームを生成する。また、読み込みが落ち着いたかどうかもこのオブジェクトで判断できる 
Subject<string>   writeSshSubject   クライアント側から SSH コマンドを送信したい場合は、writeSshSubject.OnNext(“ls -la”); 的に使う  
Subject<string>   stdOutSubject   サーバ側から改行がくる度に1行分の標準出力がイベントとしてやってくる。(これのせいで、1行がバッファを越える分だけ受信するとそこで処理が止まるという既知の問題アリ、さて、どうしたものか)  
Subject<ObservableSshStatus>   statusSubject   SSH 接続状態のステータスを受け取るためのストリーム。今のところ、読み込みが落ち着いた程度しかないので、切断されたとか、再接続制御とかにも使えるようにしたい  
string   commandPrefix   SSH コマンドの頭に強制的につける文字列。例えば、「$ env LANG=en_US.UTF-8 ls -la」みたいに英語設定で結果を出したい時とかに使用する  
CompositeDisposable   compositeDisposable   Subscribe したのを Dispose する用  
StreamWriter   stdInStreamWriter   SSH 書き込みストリーム  
ReactiveProperty<Buffer>   observableSSHBuffer   SSH読み込みストリームから読み込む度にこいつに通知され、こいつの中で改行で終わる1行分の文字列を取得できたらstdOutSubject に OnNext してくれる  
string   waitstring   未使用。多分消すかも  

サーバの自動設定とかこいつで色々できると便利と思って色々実験中

 Unity で SSH 通信ができるということは、フロントを擬人化した Live2D キャラクターにしておいて、攻撃されてる時は具合悪そうにしたり、ストレージがそろそろヤバい時教えてくれるそういうアプリもありかもしんないですね。

ツイートツイート