2015年8月18日火曜日

バッファローのルーターをC#から再起動させる

さて、今回もニッチなテーマになりました。

バッファローのルーターは比較的安定しており、バグも少なく使いやすいです。VPN機能を内蔵しているのものも比較的安価に販売されているので、非常に重宝しています。

しかし、私の使っているWZR-600DHP2というモデルですが、よくわかりませんが、たま~にVPN接続を解除した時にPPPoE接続が切断され以後再起動をかけるまでインターネットにつながらないという状況が起こってしまうようです。そうすると、VPNもつながりませんし、そもそもメール通知やTwitter通知を仕込んでるソフトも機能しなくなるので外から見ていて自宅のPC群が生きているのか全く分からない状態になってしまいます。

WZR-600DHP2には定期的に自身を再起動させる機能を持っていますが、別に不調じゃないのに再起動させる意味もありませんし、ここは、宅鯖から再起動を自動的にかける方法を探ってみました。

さて、まずはどうやったら再起動をかけられるか見当をつけてみます。
ブラウザからルーターにアクセスし、再起動画面を出してみます。そうすると、再起動画面のフレームのURLが
http://192.168.***.1/cgi-bin/cgi?req=tfr&id=55

であることがわかります。(IPアドレスは適当に各自の環境に置き換えて考えてください)
さらに、そのソースを見てみると、再起動ボタンを押すと
http://192.168.***.1/cgi-bin/cgi?req=inp&res=waiting_page.html
に飛ぶことがわかります。
よっしゃ、ここのURLを開けば再起動がかけられる!


\デーン/
URLを直にたたいただけではだめでした…。

実は、バッファローのルーターではこのような不正(っぽい)アクセスを防止するために、POSTパラメーターに適当な値を付けてURLリクエストをかけなければいけないようです。

というわけで、じっくり再起動周りのソースを調べてみると、inputタグは「submit=再起動」以外にも隠しパラメーターとしてsWebSessionnumとsWebSessionidがあることがわかると思います。この番号もPOSTパラメーターとして送信してあげないと再起動ができないわけですね。

というわけで、流れが見えてきました。
  1. http://192.168.***.1/cgi-bin/cgi?req=tfr&id=55にアクセスし、sWebSessionnumとsWebSessionidの値をパースする
  2.  http://192.168.***.1/cgi-bin/cgi?req=inp&res=waiting_page.htmlを開くときのPOSTパラメーターに、「submit=再起動」のほか、sWebSessionnumとsWebSessionidも入れてあげる
これで、プログラムから再起動ができそうです。


というわけで、さっそくWebClientを使ってhttp://192.168.***.1/cgi-bin/cgi?req=tfr&id=55のHTMLをダウンロードしようとしたところ、例外が発生しました。
System.Net.WebException
サーバーによってプロトコル違反が発生しました. Section=ResponseStatusLine
これはどうもお行儀が悪いウェブサーバーにアクセスしたときに起こる例外みたいで、.NETが厳密にHTMLプロトコルを検証した結果、違反があったときに発生するみたいです。これを回避するにはApp.config内でUseUnsafeHeaderParsingプロパティをTrueにしてあげればいいみたいです。

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
    <startup>
        <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.5.2" />
    </startup>
    <system.net>
        <settings>
            <httpWebRequest useUnsafeHeaderParsing="true" />
        </settings>
    </system.net>
</configuration>


さて、これで準備は整いました。実装していきましょう。

ちなみにですが、バッファローのルーターでは、ログインの認証にBASIC認証を用いています。BASIC認証は、WebClient.CredentialsプロパティにIDとパスワードを入れたうえで各種リクエストを送れば大丈夫です。

static Regex reg = new Regex(@"<input type=hidden name=sWebSessionnum value=(?<num>\d+)><input type=hidden name=sWebSessionid value=(?<id>-?\d+)>", RegexOptions.Compiled);
static Encoding encoding = Encoding.GetEncoding("EUC-JP");

public static async Task RebootRouter(string RouterUrl, string Password)
{
    await RebootRouter(new Uri(RouterUrl), Password);
}

public static async Task RebootRouter(Uri RouterUrl, string Password)
{
    const string user = "admin";

    using(WebClient wc = new WebClient()) {
        wc.Credentials = new NetworkCredential(user, Password);
        wc.Encoding = encoding;

        string init = await wc.DownloadStringTaskAsync(new Uri(RouterUrl, @"/cgi-bin/cgi?req=frm&frm=init.html"));

        Match m = reg.Match(init);
        if(m.Success) {
            string num = m.Groups["num"].Value;
            string id = m.Groups["id"].Value;

            string post = $"sWebSessionnum={ num }&sWebSessionid={ id }&reboot={ HttpUtility.UrlEncode("再起動", encoding) }";
            wc.Headers.Add("Content-Type", "application/x-www-form-urlencoded");
            string resText = await wc.UploadStringTaskAsync(new Uri(RouterUrl, @"/cgi-bin/cgi?req=inp&res=waiting_page.html"), post);

            if(!resText.Contains("再起動中です。"))
                new InvalidOperationException(resText);
        } else
            new FormatException("cannot parse sWebSessionnum/sWebSessionid");
    }
}

さて、こんな感じでどうでしょうかね。
結構適当な実装です。HTMLもまじめにパースせず、スペースの取り方とかは決め打ちで正規表現でsWebSessionnumとsWebSessionidを抽出しています。そして、抽出に成功したらPOSTパラメーターを作り、UploadStringTaskAsyncメソッドでそれを送信しています。

なお、バッファローのルーターのWebサーバーの文字コードはEUC-JPになっています。Encodingを指定しないと日本語で文字化けするので気を付けてください。

余談ですが、C#6.0の新機能をここでは使っています。$"~"の構文ですね。Visual Studio 2015じゃないとコンパイル通らないので、古い環境を使っている人がいたら適宜書き換えてください。

try {
    RebootRouter(@"http://192.168.***.1/", "****").Wait();
    Console.WriteLine("Router Reboot has successfully started.");
}
catch(AggregateException e) when (e.InnerExceptions.Any(p => p is InvalidOperationException || p is FormatException || p is WebException)) {
    foreach(var ie in e.InnerExceptions) {
        Console.WriteLine(ie.GetType());
        Console.WriteLine(ie.Message);
    }
}

このメソッドの呼び出しはこんな感じでいいですかね。1つ目の引数にルーターのURL、2つ目にパスワードです。非同期メソッドになっているので、例外が起きた時はAggregateExceptionになることに注意してください。ここもC#6.0になってから簡単に例外を振り分けられるようになりました。

このプログラムを実行して2分くらい待つと再起動の完了です。
めでたしめでたし。

2015年8月7日金曜日

LINQ to Twitter ver.3.1.2のUserStream

C#からTwitterするならLINQ to Twitterですが、今まで、これには不満点が1つありました。

それは、UserStreamの実装がだいぶ雑だったことです。

UserStreamはそもそもツイートだけでなく、例えばふぁぼ通知、フォロー通知、ツイ消し通知など、様々な情報が流れてきます。そのため、UserStreamは各種Rest APIのJSONとは違っていて、それこそ多種多様なデータが流れてきていました。そして、その多種多様なデータを適当に振り分けてくれるような機能が以前のLINQ to Twitterにはありませんでした。

なので、UserStreamの処理を書くときに限って、自前でJSONの処理をしなければいけませんでした。せっかくの.NET向けライブラリなのにとても残念なことになっていたわけですね。

無論、流れてくるツイートのみをパースしたければ、StatusクラスのコンストラクタにJsonData型を引き渡すことができるオーバーロードがありますから、それにぶち込んで、もしも例外等が吐かれたらツイート以外のデータだったんだなということで無視する、なんて方法はありました。私も以前からその方法をよく使っていましたし、それで事足りることもしばしばありました。

もしくは、UserStreamsParser for LinqToTwitterのような別のライブラリと組み合わせてパースさせることもできましたが、しばしば仕様変更で使えなくなってしまったり、いろいろと不都合なこともありました。

さて、ここまで勿体ぶったからには何が言いたいか見当が付いてきたかと思いますが、ついにLINQ to TwitterのほうでこのUserStreamのパース機能が実装されました。 (´◔౪◔)۶ヨッシャ!

現在のLINQ to Twitterの最新バージョンはver.3.1.2ですが、どうも3.1.0あたりから実装されたっぽいです(未検証)。

というわけで、さっそく使ってみました。

SingleUserAuthorizer auth = new SingleUserAuthorizer()
{
    CredentialStore = new SingleUserInMemoryCredentialStore()
    {
        ConsumerKey = "****",
        ConsumerSecret = "****",
        AccessToken = "****",
        AccessTokenSecret = "****"
    }
};

TwitterContext context = new TwitterContext(auth);

(from p in context.Streaming where p.Type == StreamingType.User select p).StartAsync(async s => {
    await Task.Run(() => {
        switch(s.EntityType) {
            case StreamEntityType.Unknown:    //Unknownはスルー
                break;
            case StreamEntityType.Status:    //ツイートが流れてきた
                ShowStatus((Status)s.Entity);
                Console.WriteLine();
                break;
        }                    
    });
});


こんな感じになります。
UserStreamで流れてくるStreamContentクラスにEntityTypeとEntityという2つのプロパティが追加されました。EntityTypeはStreamEntityType列挙体、Entityはobject型になっています。

例えば、ツイートが流れてくるときは、EntityTypeがStreamEntityType.Statusになっていて、EntityにStatus型のオブジェクトが入っています。なので、適宜キャストしてあげれば流れてきたツイートを適当に処理することができます。

ほかにも、例えばふぁぼられたときはStreamEntityType.Eventが流れてきます。この時のEntityはEventクラスのオブジェクトで、EventNameに"favorite"、SourceとTargetにそれぞれふぁぼった人とふぁぼられた人のUserデータが入っています。
ふぁぼられたツイートはTargetObjectだと思われますが、実際には"JsonData object"というテキストが入っているだけで、ふぁぼられたツイートはわかりません。これはおそらくLINQ to Twitterのバグでしょう。しっかり検証していませんが、おそらくフォロー通知などもEntityがEventとして流れてくると考えられるので、そのあたりで実装が不十分になっているのでしょう。これは是非とも今後のアップデートに期待したいですね。

ほかにもDeleteやDirectMessageなど、様々なUserStreamの情報に対応しているようですから、いろいろいじってみると面白いかもしれません。

 ちなみに、RetweetedみたいなEntityが無いなと思っていたら、どうもこれ、普通にStatusと一緒にやってくるみたいですね。要するに「リツイートされたツイートの投稿主が自分だったらリツイートされたってことでしょ 」という体みたいです。まあそれくらいの実装なら全然難しくはなさそうですよね。ハマらないように、念のため。

2015年8月4日火曜日

Janetterで自分あて@無しリプライをする

※今回はプラグインではなくJanetterの改造となります。 より慎重にお願いします。

さて、Janetterの登録アカウント数がTwitterの定める上限に達してから久しいですが、最近Twitter公式への複数画像の投稿機能、DMの文字数制限撤廃対応など最低限のメンテナンスはしてくれています。とてもありがたいです。

しかし1つ、Twitterの機能に追従していないことがあります。それが「自分あて@無しリプライ」です。Twitterは基本的に対応するユーザーの「@screen_name」が本文に入っていないとInReplyToが付きません。一昔前までは自分あてリプライでもそうだったと記憶していますが、最近(と言っても2年以上前ですが)、自分あてリプライに限ってその機能が撤廃されました。
それによって、140字で収まらないような長文ツイートをするときに、InReplyToを付けることによってよりわかりやすく一連のツイートを関連付けることができます。

さて、どうやるのかですが、これはJanetterの改造になります。ですが、とてもシンプルです。
Janetter4.3.1.0の場合ですが、

C:\Program Files (x86)\Janetter2\Theme\Common\js\janet\tweetedit.js

の540、541行目が該当するコードになっております。
if(self._replayId>0 && textarea.val().indexOf('@' + self._replayTo)==-1)
    self.removeReplyTo();

InReplyToが付いていて、かつそのscreen_nameがテキストボックスに含まれてなければReplyToを削除するようにできています。

ここに条件を加えてあげましょう。「さらに、そのscreen_nameが自分のアカウント名じゃなかったら」を追加することで想定した動作になります。
if(self._replayId>0 && textarea.val().indexOf('@' + self._replayTo)==-1 && self._replayTo.toLowerCase()!=self._account.screen_name.toLowerCase())
    self.removeReplyTo();

これだけで完成です。自分あてリプライのときに限って、InReplyToが削除されなくなりました。

おそらくこのファイルはJanetterの更新で入れ替えられますから(このファイル内でDM字数制限撤廃機能が実装されていますし)、そのたびに修正する必要が出てくるかと思います。 その場合、もちろん何行目かも変わってくる可能性がありますが、まあその辺は臨機応変に。