hnwの日記

BuffaloのUSB無線LANアダプタの返す製造元の文字列を解読する

私の手元に「Buffalo WLI-UC-AG300N」というUSB無線LANアダプタがあるのですが、Macの「システム情報」で見ると製造元が「敇瑭步挮浯䩟」となっていることに気づきました。

バッファローとかメルコとか書いてあるなら分かりますが、少なくとも日本語ではありませんし、簡体字なり繁体字なりだとしても不自然に思えます。となると、一体何が表示されているのでしょうか?文字コード警察的な意味で興味を持ったので、調べてみました。

謎解き(1) 何が書いてあるのか

この記事の執筆時点では「敇瑭步挮浯䩟」でGoogle検索しても同じ無線LANアダプタの情報が1件見つかるだけで、そんなメーカーは地球上に存在しなさそうなことがわかります。

ネット上にも情報が無いときに頼れるのは自分の直感だけです。そこで、私は謎の漢字列をUTF-16にしてみることにしました。

上記PHPファイルをUTF-8で保存して実行すると、次の文字列を得ます。

Gemtek.com_J

なんと7bitの可読文字が現れました。UTF-16は原則2バイトで1文字を表しますが、UTF-16の6文字を無作為抽出した場合に対応する12バイト全てがASCIIの7bit可読文字になる確率は0.0007%以下です。これは偶然なわけがありません。

この文字列のGemtekというのは台湾のメーカー「Gemtek Technology Co., Ltd」のことでしょう。同社はバッファロー社の無線LAN製品のOEM仕入れ先としても有名です。

実際、下記URLに「Buffalo WLI-UC-AG300N」のOEM元がGemtek社であることが書かれています。

そんなわけで、隠された文字列は「Gemtek.com_J」であることがわかりました。

謎解き(2) なぜ文字化けが起きたのか

さて、謎の漢字列はGemtek社を表すようですが、どうすればこんな不思議な文字化けが起きるのでしょうか?

実は、下記引用部の通り、USB 2.0の文字エンコーディングはUTF-16LEだと決められています。

Unicode ECN: Released in February 2005.
This ECN specifies that strings are encoded using UTF-16LE. USB 2.0 specified Unicode, but did not specify the encoding.

https://en.wikipedia.org/wiki/USB

つまり、USBで「abc」という文字列を表したい場合、「0x61 0x00 0x62 0x00 0x63 0x00」の6バイトにする必要があるわけです。これを知らずに(もしくは後で作業しようと思って忘れて)実装してしまうと、UTF-16としては不思議な文字列になるわけです。これが今回の文字化けの真相でしょう。

まとめ

WLI-UC-AG300Nの製造元文字列「敇瑭步挮浯䩟」について調べました。

  • 「敇瑭步挮浯䩟」は本来「Gemtek.com_J」となるはずだった
    • これは台湾のメーカーGemtek社を意味する
  • USBの仕様上UTF-16LEで記述すべきところをASCIIで記述したため文字化けが起きた

イースターエッグ的なものである可能性もゼロではありませんが、受託ビジネスでそんな無駄なリスクは取らないと思います。

PHPの連想配列は常にin_arrayより速いのか

プログラムを書いていると、入力値が辞書に含まれているかを調べたいようなことがあります。たとえば、ユーザーに都道府県名を入力させて、それが正しい都道府県名であるかどうかを調べたい、というようなことがあるかもしれません。

このような内容をPHPで書く際、キーに都道府県名を持つような連想配列を作る習慣がある人は多いはずです。これは典型的な連想配列の使い方といえるでしょう。

<?php
$prefs = array(
    "北海道" => true,
    "青森" => true,
    // ...
    "沖縄" => true,
);

if (isset($prefs[$input])) {
    // 都道府県名が正しい時の処理
}

一方で、in_array関数を使うやり方も考えられます。

<?php
$prefs = array(
    "北海道",
    "青森",
    // ...
    "沖縄",
);

if (in_array($input, $prefs)) {
    // 都道府県名が正しい時の処理
}


突然ですが、クイズです。この2つのうち、どちらの方が良いコードでしょうか?

連想配列から特定のキーを持つ要素にアクセスするコストはO(1)であるのに対し、配列から特定の要素を探すコストはO(N)ですから、一般論としては連想配列を使う方が好ましいと言えるでしょう*1。しかし、計算量の議論はNが大きい場合には正しいですが、Nが小さい範囲では係数や定数項など他の要素の影響も無視できないはずです。常に前者の方が良いコードであると言えるだけの材料を我々は持っているのでしょうか。

そこで、今回PHP 5.6.30およびPHP 7.1.5を用いて、配列サイズとアクセス回数を変えながら両者の速度を測定してみました。本稿ではその結果を紹介します。

PHP 5系では連想配列アクセスの方が速い


今回、配列と連想配列とで、辞書データの構築時間と検索時間の和を比較してみました。というのも、一定規模の配列になると構築にかかる時間も無視できないため、たとえ検索が遅くても構築が速い方がトータルでは有利になる可能性があるからです。

早速ですが、PHP 5.6.30での実験結果のグラフを示します。両対数グラフなので見方に注意してください。

配列サイズというのは辞書に含まれる単語数です。また、アクセス回数というのはその辞書に対する検索の回数です。上記グラフではアクセス回数を固定して配列サイズを倍々で増やしていく、ということをしました。

これを見ると、アクセス回数が8回ある状況であれば連想配列の方が1.2から1.5倍程度は速いと言えそうです。また、アクセス回数128回になると差が絶望的に開きます。検索回数が多い場合は(当然ですが)検索の計算量の差が支配的であることがわかります。

連想配列の方がデータの構築に時間がかかるためアクセス回数2回では互角程度になりますが、互角であれば連想配列を使った方が事故は少なそうです。

PHP 7系では配列アクセスに逆転されることがある

面白いことに、PHP 7系だと状況が変わってきます。PHP 7.1.5, アクセス回数8回のグラフを見てみましょう。

配列サイズが小さいうちは連想配列有利だったのが、配列サイズ2048あたりで配列が逆転し、サイズ65536あたりで連想配列が再逆転する、というような結果になっています。何が起こったのでしょうか。

配列サイズが小さいうちは配列と連想配列とで構築コストに大きな差はありません。この段階で差になってくるのは、in_array($key, $array)が関数呼び出しであるのに対し、isset($array[$key])はopcodeにコンパイルされて関数呼び出しにならないということです。PHPの関数呼び出しは他のopcode実行に比べるとコストが高いので、この差で連想配列の方が速くなっているのです。

配列サイズが中程度になってくると、配列と連想配列の構築コストの差が支配的になってきます。PHP 7からは真の配列と言うべきデータ構造が導入されており*2連想配列と比べて簡潔なデータ構造で済むようになりました。このため、同じサイズであれば連想配列より配列の方が構築コストが低く、PHP 5のときは見られなかった逆転現象が起こるというわけです。

さらに配列サイズが大きくなってくるとO(N)とO(1)というアクセス時の計算量の差が効いてくるので、ふたたび連想配列の方が有利になります。

このように、アクセス回数8付近では複雑な状況が見られるわけです。一方、アクセス回数32であれば基本的には連想配列の方が有利となります。

また、アクセス回数8192回では絶望的な差がついてきます。

このように、アクセス回数が大きくなってくると予想通り連想配列の方が有利だということがわかります。

一方、アクセス回数が少ない場合に限れば配列を採用する可能性もゼロではないと言えそうです。とはいえ、プログラムの改修などでアクセス回数が増える可能性を考えると基本的には連想配列を採用しておく方が無難でしょう。

この文章をどう読むかについての補足(2017/5/25追記)

一般論として、普段のコーディングにおいてマイクロベンチの結果を参考にしすぎるのは良い習慣ではありません。たとえば、echoとprintfとでどちらが速い、みたいな内容に引っ張られて書くコードを変える必要はないでしょう。もしそれがボトルネックになっていることが判明したら、判明した後で変えれば良いはずです。

その一方で、マイクロベンチの速度差がなぜ生まれたのか、その原因を調べることで何らかの新しい知見を得ることもあるはずです。本稿で一番お伝えしたかったのは、その面白さについてでした。

加えて、今回は計算量の話題やオーダーの違いの怖さ、といった部分も盛り込んだわけですが、このあたりは人によって感覚が異なるのかもしれません。私個人の意見としては計算量の観点で不利な実装を採用する際は常に理由が必要だと思っており*3、本稿もそのような前提で書かれていますが、異なる意見があってもおかしくは無いと思います。

まとめ

PHP連想配列と配列のin_array()とで、どちらが有利かを検討してみました。

  • PHP 5.6では連想配列が有利
  • PHP 7では「真の配列」のおかげで配列が有利な状況もありうる
    • アクセス回数小、配列サイズ中程度の場合に限る
    • 配列の方が検索コストは高いが、構築コストが低いため
  • 現実には配列が正解という状況は少ないはず
    • アクセス回数と配列サイズの両方が極端に少ない場合は配列の方が読みやすいかも

連想配列の方が有利だというのは特に驚きのない結論ですが、PHP 7の「真の配列」による性能向上が垣間見えたのは面白いと感じました。

ネイティブコードにコンパイルするような言語であれば、辞書サイズが小さいときは連想配列よりも配列が正解という状況もありうると思うんですが、PHPの場合はin_array()が関数呼び出しであることのペナルティが大きく、そのような状況は確認できませんでした。

追試用資料

筆者の手元のマシンはCore M採用のノートマシンであり、Turbo Boostのせいで結果が安定しなかったりするので、結果の信用性は保証できません。追試したいと思われた方は下記URLをご確認ください。

*1:たとえば入力値が辞書に存在しないことを示す場合に、配列だと全要素とN回の比較が発生しますが、連想配列だとハッシュ値が一致する要素(平均では1要素)と比較すれば良いので、計算の効率が格段に違うことになります

*2:参考資料:「PHP7で変わること ——言語仕様とエンジンの改善ポイント

*3:逆にechoとprintfはオーダーが同じなのでどちらでも良いと思っています

PHPのシンボルテーブルを覗いてみる

PHPのシンボルテーブルというのはC実装のレベルの用語で、その時点で有効な変数テーブルのことを指します。つまり、グローバルスコープならグローバル変数を管理する変数テーブル、関数スコープならローカル変数を管理する変数テーブルの意味になります。


今回、自作エクステンションhnw/php-arraydumperでシンボルテーブルをvar_dump()するだけの関数symbol_table_dump()を実装してみました。下記のように使います。

<?php
function foo($a = 1) {
    $b = 2
    symbol_table_dump();
}
foo("bar");


出力は次のようになります。

array(2) {
 ["a"]=>
 string(3) "bar"
 ["b"]=>
 int(2)
}


このシンボルテーブルは$$varのようなイカれた変数アクセスの際に参照する必要がありますが、そうでも無い限り不要です。通常のローカル変数はコンパイル時に全部解決できてしまうので、通常の変数アクセスのみであれば実行時にシンボルテーブルへのアクセスは発生しないのです。


実際、PHP 5.3以降ではローカル変数に対応するシンボルテーブルは必要になるまで作られません。$$varのような変数アクセスがあったタイミングで内部的にzend_rebuild_symbol_table()が呼び出され、そのスコープに対応するシンボルテーブルが構築されます。


逆に言うと、$$varのような変数アクセスを多用するのは性能面でペナルティがあると言えるかもしれません。もちろん致命的な性能差が出るほどではないでしょうし、そもそもそんな変数アクセスをしたい状況が珍しいとは思いますが。

Windows Subsystem for Linux上でphp-fpmを動かしてみた

最近のWindows 10にはLinuxバイナリをそのまま動かすような仕組みが導入されています。これは Windows Subsystem for Linux (WSL) とか Bash on Ubuntu on Windows (BoW) などと呼ばれているもので、VM実行でなくWindowsネイティブでLinuxを動かすという意欲的な取り組みです。


この環境で最新版のPHPソースコードからビルドし、アプリケーションサーバとして動作させてみました。


本稿執筆時点(2017年5月)ではWSL自体がまだ不安定な印象ですが、nginx+php-fpmを動作させることができました。以下はWindowslocalhost 80番ポートでPHPが動作している証拠画像です。「System」欄のunameの表示にMicrosoftという文字列が入っているのがオシャレですね。



以下、WSL上でnginx+php-fpmを動かすまでの手順を紹介します。

WSLのセットアップ

Bash on Ubuntu on Windows - Installation Guide」に従ってセットアップします。

PHPのビルド

まずはWSL上でPHPをビルドしてみましょう。


コンパイル済みバイナリをaptでインストールしてもいいのですが、普通にビルドできる程度にWSLが安定してるのかな?という興味から自前ビルドしてみました。


まずは必要パッケージをインストールします。

$ sudo apt update
$ sudo apt install build-essential libxml2-dev zlib1g-dev libcurl4-openssl-dev \
    libjpeg62-dev libpng12-dev libmcrypt-dev libreadline-dev libtidy-dev \
    libxslt1-dev libssl-dev libbz2-dev git autoconf


今回はphpenv+php-buildでPHPをビルドします。

$ curl -L https://raw.github.com/CHH/phpenv/master/bin/phpenv-install.sh | bash
$ mkdir $HOME/.phpenv/plugins
$ cd $HOME/.phpenv/plugins
$ git clone https://github.com/php-build/php-build.git


下記の内容を .bashrc追記してシェルを再起動します。

PATH=$HOME/.phpenv/bin:$PATH
eval "$(phpenv init -)"


あとはphpenv経由でPHPをビルドするだけです。

$ PHP_BUILD_EXTRA_MAKE_ARGUMENTS=-j4 phpenv install 7.1.4


ビルドにはかなり時間がかかるので注意してください。筆者の手元のマシン(Thinkpad X260、Intel Core i7 2.5GHz)で30分以上かかりました。同じビルドが12インチMacBook(Early 2016、Intel Core m5 1.2GHz)で10分を切ることを考えると、まだお仕事で使えるレベルではない印象です。今後のパフォーマンスチューニングに期待しましょう。


ちょっと遅いことを除けば、ビルド自体は正常に終了します。ここまでは普通のLinuxと大差ありません。

nginx+php-fpmの設定

つぎに、nginxをaptでインストールします。

$ sudo apt install nginx


設定ファイル /etc/nginx/sites-enabled/default の設定のうち、下記部分のコメントアウトを外して有効化します。

        # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
        #
        location ~ \.php$ {
                include snippets/fastcgi-php.conf;
                fastcgi_pass 127.0.0.1:9000;
        }


nginxを再起動します。

$ sudo service nginx restart


php-fpmの方はデフォルトの設定ファイルをコピーしてそのまま使います。

$ cd $HOME/.phpenv/versions/7.1.4/etc/
$ cp php-fpm.conf.default php-fpm.conf
$ cp php-fpm.d/www.conf.default php-fpm.d/www.conf


下記コマンドでphp-fpmを起動します。

$ $HOME/.phpenv/versions/7.1.4/sbin/php-fpm


ここで /var/www/phpinfo.php などを設置すれば、nginx経由でphp-fpmを利用することができます。


ただし、この設定だと1秒に1回下記のエラーが出続けます。getsockopt(2) の実装がまだ不完全のようですね。

[01-May-2017 20:06:06] ERROR: failed to retrieve TCP_INFO for socket: Protocol not available (92)
[01-May-2017 20:06:07] ERROR: failed to retrieve TCP_INFO for socket: Protocol not available (92)
[01-May-2017 20:06:08] ERROR: failed to retrieve TCP_INFO for socket: Protocol not available (92)


TCPでのプロセス間通信に問題があるならunix domain socketにすればいいじゃない、と考えてunix domain socketも試してみたのですが、phpinfo() の結果が途中で切れてしまうようです。こちらも実装が不完全なようで、16KB超のデータがうまく扱えないとか、そんな制限がありそうな挙動に見えました。TCPの方がまだ安定している状況だといえるでしょう。

まとめ

WSLはまだbeta版なので過度の期待をする方が悪いと言えばそうなのですが、まだ性能が出なかったり未実装のシステムコールもあったりで、お仕事で使えるようになる日は遠いかな、という印象を持ちました。


とはいえ、長期的に期待できる技術なのは間違いないところでしょう。さらなる安定と正式リリースが待ち遠しいですね。

アメリカで何年の4月1日がサマータイムだったか調べてみた

日付関連のテストケースを書いていたら、ロサンゼルスの1970年4月1日0時はサマータイムではないけれど、2012年4月1日0時はサマータイムであることに気づきました。何かの間違いじゃないかと思って改めて調べてみたところ、ロサンゼルスで4月1日がサマータイムだった年は以下の5つの時期に含まれることがわかりました。

  • 1918-1919年
  • 1942-1945年(War Time)
  • 1948年
  • 1974-1975年
  • 2007年以降、現在まで


このうち、1番目は第一次世界大戦中に制定された「標準時間法」 (Standard Time Act)によるものです。アメリカでは初のサマータイム導入でしたが、不評のため2年で廃止されたとか(おそらく終戦の影響もあったでしょう)。


2番目は第二次世界大戦時の省エネ対策として通年の「War Time」が実施されたもので、1942年2月9日から1945年9月30日まで継続的に運用されました。


3番目は1948年にカリフォルニアで電力危機があり、その対策でカリフォルニア州のみ3月14日から通年のサマータイムが実施されたようです。このころ各州バラバラでサマータイムを実施していたようですが、開始時期は大半が4月でした。


4番目は第一次オイルショック(1973年)によるもの。1967年から全米でサマータイムが実施されていますが、この開始時期は4月第1週からでした。しかし、オイルショックの影響により1974年は1月6日から、1975年は2月23日からサマータイム開始だったとのこと。


5番目は「包括エネルギー政策法」(Energy Policy Act of 2005)が2007年から施行され、サマータイム開始が4月第1週から3月第2週にズレたことによるもの。


サマータイムについて調べていたはずが、歴史の教科書に載っているような出来事が関係してくるのは面白いですね。

上のリストを作る方法

上記リストは以下のPHPスクリプトを使って求めました。

<?php
for ($i = 1900; $i <= 2017; $i++) {
    $dt = new DateTime("$i-04-01 00:00:00",
                       new DateTimezone("America/Los_Angeles"));
    var_dump($dt->format("c T"));
}


このスクリプトの出力を下記のようにgrepすれば標準時でないもの(≒夏時間)が抜き出せます。

$ php dst-investigate.php | grep -v ST
string(29) "1918-04-01T00:00:00-07:00 PDT"
string(29) "1919-04-01T00:00:00-07:00 PDT"
string(29) "1942-04-01T00:00:00-07:00 PWT"
string(29) "1943-04-01T00:00:00-07:00 PWT"
string(29) "1944-04-01T00:00:00-07:00 PWT"
string(29) "1945-04-01T00:00:00-07:00 PWT"
string(29) "1948-04-01T00:00:00-07:00 PDT"
string(29) "1974-04-01T00:00:00-07:00 PDT"
string(29) "1975-04-01T00:00:00-07:00 PDT"
string(29) "2007-04-01T00:00:00-07:00 PDT"
string(29) "2008-04-01T00:00:00-07:00 PDT"
string(29) "2009-04-01T00:00:00-07:00 PDT"
string(29) "2010-04-01T00:00:00-07:00 PDT"
string(29) "2011-04-01T00:00:00-07:00 PDT"
string(29) "2012-04-01T00:00:00-07:00 PDT"
string(29) "2013-04-01T00:00:00-07:00 PDT"
string(29) "2014-04-01T00:00:00-07:00 PDT"
string(29) "2015-04-01T00:00:00-07:00 PDT"
string(29) "2016-04-01T00:00:00-07:00 PDT"
string(29) "2017-04-01T00:00:00-07:00 PDT"


上のように、タイムゾーン名がPDT(Pacific Daylight-saving Time、太平洋夏時間)やPWT(Pacific War Time、太平洋戦争時間)などと表示されているのがわかります。


PHPの日付関数はTime Zone Databaseを元にしているので、他の言語でも同じ結果を得る方法があると思います。

感想など

調べてみると、省エネ対策としてカジュアルにサマータイムを実施している歴史がわかって面白いですね。サマータイムが身近でない日本人の感覚だと「本当に省エネ効果あるの?」という気もしちゃいますけど、きっと効果があるからやってるんでしょう。


また、州ごとに夏時間の適用状況が違うのもアメリカならではです。上のスクリプトを America/Detroit とか America/Phoenix などで試すと全然違う結果になって面白いです。

PHP 7.1.3で時刻の差を取ると時々1マイクロ秒ズレる

本日はエイプリルフールなので、ウソでも本当でも誰も困らないPHPのバグの話をします。


PHP 7.1.0からPHPのDateTimeクラスでマイクロ秒の扱いを強化しているようで、挙動やコードの変更がチラホラ見受けられます。(参考:「PHP 7.1からDateTimeが現在時刻のマイクロ秒まで見るようになった - Qiita」)


時刻と時刻の差分を扱うDateIntervalクラスでもPHP 7.1.0以降マイクロ秒に対応したようで、DateInterval::formatメソッドもマイクロ秒の表示に対応しているようです。さっそく実験してみましょう。

<?php
$dt1=new DateTime("2000-01-01 00:00:00");
$dt2=new DateTime("2006-01-02 03:04:05.6");
$interval = $dt1->diff($dt2);
var_dump($interval->format("%R%Y-%M-%D %H:%I:%S.%F"));
/* Output: string(25) "+06-00-01 03:04:05.600000" */


パラメータ%Fで差分のマイクロ秒が表示できました。ところで、次のような例を試すと奇妙なことに気づきます。

<?php
$dt1=new DateTime("2000-01-01 00:00:00");
$dt2=new DateTime("2006-01-02 03:04:05.000251");
$interval = $dt1->diff($dt2);
var_dump($interval->format("%R%Y-%M-%D %H:%I:%S.%F"));
/* Output: string(25) "+06-00-01 03:04:05.000250" */


0と251の差なので「000251」と表示されるはずが「000250」となっており、1マイクロ秒ズレた結果になっています。


ドキュメント化もされていない機能なので誰も困らないと思いますが、一応バグ報告しておきました。

ポートノッキングで10秒間だけsshdを公開する設定

先日Twitterに次のような書き込みをしたところ思ったより反応が良かったので、詳細の設定を紹介します。

といっても特殊なことをしたわけではなく、knockdでポートノッキングの設定を行い、iptablesと組み合わせて実現しました。

ポートノッキングとは

ポートノッキングというのは、決められたポートを決められた順番で叩くことでファイアーウォールに穴を空けられるような仕組みのことです。ポートノッキングを使えば、TCPの7000番、8000番、9000番の3ポートにパケットを送りつけると22番ポート (SSH) へのアクセスが許可される、といった設定ができます。

ポートノッキングの実現にはknockdというデーモンを使うことが多いようです。knockdはlibpcapと組み合わせて使う前提のツールで、どのポートもlistenせずにポートノックを監視できるのが特徴です。

iptablesの設定

早速ですが、筆者の行った設定を紹介しましょう。今回の対象サーバはDebian 8ベースでした。まずはiptables関連のパッケージをインストールします。

$ sudo apt-get install iptables iptables-persistent

次にiptablesの設定ですが、TCP22番ポート (SSH) へのアクセスを全てふさぎます(6行目)。ただし、トラブル時の非常用経路(下記の例では 192.0.2.0/24)からのアクセスだけは許可しておき、以降の設定はこちらから行います(5行目)。

$ sudo iptables -t mangle -A PREROUTING -p tcp --dport 443 -j MARK --set-mark 443
$ sudo iptables -A INPUT -i lo -j ACCEPT
$ sudo iptables -A INPUT -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
$ sudo iptables -A INPUT -p tcp -m tcp --dport 22 -m mark --mark 443 -j ACCEPT
$ sudo iptables -A INPUT -s 192.0.2.0/24 -p tcp -m tcp --dport 22 -j ACCEPT
$ sudo iptables -A INPUT -j DROP

TCP443番からのパケットにはPREROUTINGチェインでマークを付けておくように設定し、マークの付いたパケットのTCP22番へのアクセスは許可する、という設定にしてあります(1行目、4行目)。これはknockdの方の設定と組み合わせて利用します。

最後に、次のように設定を保存します。

$ sudo netfilter-persistent save

ポートノッキングの設定

今度はポートノッキングの設定です。まずはknockdをインストールしましょう。

$ sudo apt-get install knockd

次に、/etc/knockd.confを次のように書き換えます。

[options]
	UseSyslog

[SSH]
	sequence    = 53:udp,443:tcp,123:udp
	seq_timeout = 5
	command     = /sbin/iptables -t nat -A PREROUTING -s %IP% -p tcp --dport 443 -j REDIRECT --to-port 22
	tcpflags    = syn
	cmd_timeout = 10
	stop_command = /sbin/iptables -t nat -D PREROUTING -s %IP% -p tcp --dport 443 -j REDIRECT --to-port 22

sequenceはポートノッキングのポートと順序を指定するものです。今回はUDP53番 (DNS)、TCP443番 (HTTPS)、UDP123番 (NTP) という3つのポートを叩くとポートノッキングが成立するよう設定しました。もちろん、これらのポートにはデーモンは立てていません。

commandはポートノッキング成立時に実行するコマンドで、TCP443番へのパケットを22番ポートに転送するようにしました。上で紹介したiptablesの設定と組み合わさることで、443番へアクセスするとsshdが返事を返す状態になるわけです。

この設定はポートノッキング元のIPアドレスに対してのみ有効になっているということにも注目してください( command の指定にある -s %IP% で実現しています)。ポートノッキング成立と同じタイミングで別の誰かが443番ポートに接続しようとしても単にファイアウォールがパケットを落としてしまうだけで、sshdにアクセスされることはありません。

cmd_timeoutstop_commandはポートを閉じるための設定です。この例では、ポートノッキング成立から10秒後に先ほどの443番から22番への転送設定を削除しています。

最後にknockdを再起動します。

$ sudo service knockd restart

これでポートノッキングを行うことで10秒だけSSHアクセスが可能になる、という設定が完成したわけです。

ポートノッキングに利用するポートの選択

ポートノッキングに利用するポートは本来何でもいいのですが、たまに外向きのアクセスが一部制限されている環境があります。社内ネットワークなどで必要なポートのみ開放されている場合もあるでしょうし、最近は多くのインターネットプロバイダで外向きのTCP25番ポートがブロックされているはずです。

そうした観点から、多くの環境で開いているのではないか?と考えて筆者はDNS、NTP、HTTPSという3つを選びました。身近な環境で外向きアクセスが制限されていないのであれば、7000番以降など後ろの方のポートを使った方がいいかもしれません。

.ssh/configの設定

このポートノッキングは手動で行うこともできますが、毎回手動で叩くのは面倒すぎますよね。それに、モタモタしていると10秒経ってSSHのポートが閉じられてしまうかもしれません。

そこで、ポートノッキングするコマンドを.ssh/configに組み込んでポートノッキングを自動で行うようにしてみましょう。

まずはknockコマンドをインストールします。私は手元のマシンはMacなので、次のようにHomebrewでインストールします。

$ brew install knock

下記のように.ssh/configProxyCommandでknockコマンドを指定することで、SSHログインの直前にポートノッキングを行います。

Host host1.example.com
  Port 443
  ProxyCommand bash -c '/usr/local/bin/knock -d 100 %h 53:udp 443:tcp 123:udp; sleep 1; exec /usr/bin/nc %h %p'

knockコマンドの-dはポートを叩く間隔(ミリ秒)です。テザリング回線を使う場合など待ち時間が短すぎるとうまく動作しないことがありますので、サーバ側のログを見ながら調整してください。

こうしておけば、普段はポートノッキングの存在すら忘れて他のマシンと同様に利用できるわけです。

まとめ

以上で、ポートノッキングの順序を知らない人はSSHログインを試みることすらできない、それでいて設定を知っていれば全自動でポートノッキングを行ってSSHログインできるような環境が構築できました。

念のため補足しておくと、ポートノッキングはセキュリティの観点では気休め程度のテクニックです。ポートノッキングに利用するパケットはそのままネットワーク上を流れますから、途中経路でのネットワーク盗聴などがあれば簡単に再現できてしまいます。間違ってもtelnetdなどセキュアでないサービスの公開にポートノッキングを使ってはいけません。

逆に言うと、ポートノッキングは元々セキュアなサービスを隠すような用途でしか使えません。基本的には「カッコいい」以外のメリットがない仕掛けだと言えるでしょう(今回は実益があったので設定したのですが、それは別の機会に紹介します)。

また、個人的にはknockdを業務で利用することはあまりオススメしません。引き継ぎが面倒になるだけでセキュリティ面の効果は気休め程度、と考えればデメリットの方が大きいくらいだと思います。

一方で、個人用サーバであれば無駄な苦労をしても困るのは自分だけですから、面白いと感じたことはドンドンやればいいと思います。こうした遊びを通じてネットワークまわりの知識が増えていけば、いずれ業務にも生きてくるはずです。