2011/01/29

重複起動を防止するRuby用のdaemonクラスライブラリ

SourceForge.jpにできたPersonalForgeのGitリポジトリ機能を使ってみました。

登録したのはCouchDB周りでDBのメンテナンス用Daemonを作るために作成したRuby用Daemonライブラリyadaemon.rbとサンプルコードです。

有名なdaemonライブラリは便利そうにみえますが、今回は使わない機能が豊富で、欲しかった機能は重複起動を防ぐ機能だったので自分で作成する事にしました。

今回はRuby 1.9.2用に作成しました。

雛型コード

#!/usr/local/bin/ruby
#-*- coding: utf-8 -*-
require 'yadaemon'
opts = { "daemon"=>true,"euid"=>1000 }
daemon = YaDaemon.new("testapp","test.pid","/tmp",opts)
daemon.run do |pid|
  i = 0
  while daemon.running
    open("/tmp/testapp/test.log","w") do |f|
      f.write(format("updated: %d\n", i))
      f.flush
    end
    i += 1
    sleep 5
  end
end

PersonalForge

Gitリポジトリ

よくあるGitリポジトリのビューがPersonalForge Summaryページから提供されています。

コードのチェックアウト

gitを使ってチェックアウトする事ができます。

$ git clone git://git.pf.sourceforge.jp/gitroot/y/ya/yasundial/MyDaemonWrapper4Ruby.git

基本的な挙動

よくあるdaemonと同じように、pidファイルを使います。

runメソッドに指定したブロックを実効する前に、いくつかのチェック作業を行ないます。

  • シナリオ# - メインラインシナリオ
  • A1 - ユーザがプロセスを起動する
  • A2 - 既に実行しているプロセスがないかpidファイルの存在をチェックする
  • A3a - pidファイルが存在する場合、中に記述されている番号のプロセスが存在するか確認する
  • A3b - pidファイルが存在しない場合、pidファイルが作成できる事を確認する
  • A4 - piddirが書き込み可能か確認する
  • A5 - forkする場合は、forkする
  • A6 - 既存pidファイルにpidを保存する
  • A7 - rootで起動して、かつ、指定がある場合、eUID, eGIDにswitchする
  • A8 - runメソッドがyieldを実行し、ユーザが指定したジョブを実行する
  • A9 - ユーザが指定したジョブを実行した後は特に何もせず後続の処理を続ける

オプション

インスタンス化の手順

daemon = YaDaemon.new(appname, pidfile, pidpdir, options)

YaDaemonクラスのインスタンスを作成する際に指定できるパラメータは次の通りです。

  • appname: appname used for sub-directory name of the pidpdir.
  • pidfile: pid filename
  • pidpdir: pid parent dir
  • options: Hash object
    • debug => true/false
    • daemon => true/false
    • euid => number
    • egid => number
    • perms => number

意味と使い方を順番に解説していきます。

appname

パスを含まないディレクトリ名にとることができる文字列を記述します。

名前はアプリケーションの名前の意味ですが、実際には"pidpdir"直下にサブディレクトリを作るためのディレクトリ名として使われます。

euidが指定されている場合には、所有者がeuidで指定したユーザになります。

pidfile

パスを含まないファイル名を記述します。

指定したファイルにPID番号が書き出されます。

ファイルオーナはプログラムを実行したユーザのUIDになります。

pidpdir

ディレクトリへの絶対パスを記述します。

pidpdir自体のowner/groupなどのパーミッションは一切変更されません。

ディレクトリが存在していれば、このディレクトリappnameのディレクトリが作成されます。

options: debug (default: false)

オプションが指定されている場合にはデバッグメッセージがpidfileと同じディレクトリ(pidpdir/appname/)に"debug.log"の名前でログファイルが出力されます。

options: daemon (default: false)

trueの場合には、プロセスをforkしてstderr/stdout/stdinを切り離します。

d.j.b.のdaemontoolsと一緒に使う事を想定しているため、標準ではfalseに設定されています。

options: euid (default: Process::euid)

実効UIDを整数値で指定。文字列でグループ名を指定する事はできません。

プロセス特定のユーザで実効する場合には、ファイルの権限変更などに供えて、予防的にこのオプションを指定する事をお勧めします。

過ってrootユーザで実効した場合でも、指定したeuidに遷移した後にrunメソッドが呼ばれます。

options: egid (default: Process::egid)

実効GIDを整数値で指定。文字列でグループ名を指定する事はできません。

options: perms (default: 0711)

整数値で指定する必要がありますが、0711は711とは異なります。

0711,02711のような8進数をベースに指定します。

2011/1/31追記:stop/restartオプションの追加

pidファイルの中にPIDを埋め込んでいるので、これを利用してstop()force_stop()の2つのメソッドを持っています。

使い方の例はsf.jpのGitリポジトリの中に入れてありますが、簡単にサンプルコードだけを載せておきます。

トップにあるコードで、daemon.run()の呼び出しの前に、次のようなコードを書いておきます。

stop()メソッドを使った例

if ARGV[0] == "restart"
  begin
    daemon.stop
    while daemon.check_proc
      sleep 1
    end
    puts "running process was terminated."
  rescue
    puts $!
    puts "failed to restart process."
  end
elsif ARGV[0] == "stop"
  begin
    daemon.stop
    while daemon.check_proc
      sleep 1
    end
    puts "running process was terminated."
  rescue
    puts $!
  end
  exit
end

これを呼ぶと @piddirの直下に"stop.txt"ファイルが作成され、whileの条件式にしているdaemon.running()がfalseを返すようになります。

長期間sleepするような作りになっていると、daemon.running()メソッドが呼ばれるまで何も反応しませんが、ちょっとコードを工夫すれば問題なく安全に停止することができるでしょう。

2011/01/25

WebSVNに"lost+found"を無視させる

自作のコードのいくつかはSubversionで管理していて、Alix上にApacheを入れてWebDAVとWebSVNで管理しています。

今回USBメモリ上にリポジトリを作成したのですが、そこをWebSVN 2.0で表示させるとlost+foundをリポジトリとして扱おうとしてエラーが表示されてしまいます。

リポジトリの表示を自動化せずに、全て手動で$config->addRepository()を/etc/websvn/config.php内で実行するのは、どんどんリポジトリを追加していて、あまりにも面倒なので削除パターンを追加することにしました。

環境

WebSVNは次の環境で稼働しています。

  • HW: Alix 2c3
  • OS: Debian lenny (5.0.8)
  • Apache: 2.2.9-10+lenny9
  • WebSVN: 2.0-4+lenny1

WebSVNの最新版は2.3系列で、それについては最後に少しふれています。

Ubuntu 10.10で確認した範囲ではWebSVN 2.3.1では、apacheプロセスが読み取れない、SVNリポジトリではないディレクトリが存在していても問題なく動いています。

挙動の変更内容

設定ファイルの中でリポジトリが格納されているディレクトリを$config->setParentPath()で指定していると想定しています。

PHPの内部ではopendir()で展開されていますが、lost+foundなどのシステム上必要なディレクトリを省くためにignoreRepoPathPattern変数を追加しました。

既存コードに倣って直接変数への代入ではなく、addIgnoreRepoPathPattern関数を使って追加する仕様にしています。

修正内容

変更を加えたのは設定ファイルのconfig.phpと、それを元にリポジトリをリストアップするconfigclass.phpファイルです。

config.phpへの設定追加

--- /etc/websvn/config.php.orig	2011-01-25 10:45:15.000000000 +0900
+++ /etc/websvn/config.php	2011-01-25 11:02:04.000000000 +0900
@@ -1,4 +1,8 @@
 <?php
+
+// $config->addIgnoreRepoPathPattern("/my/"); // test purpose only
+$config->addIgnoreRepoPathPattern("/lost\+found/");
+
 // WebSVN - Subversion repository viewing via the web using PHP
 // Copyright (C) 2004-2006 Tim Armes
 //

configclass.phpの修正内容

--- /usr/share/websvn/include/configclass.php.orig	2011-01-25 10:10:19.000000000 +0900
+++ /usr/share/websvn/include/configclass.php	2011-01-25 11:00:23.000000000 +0900
@@ -133,6 +133,8 @@
    var $contentEnc;
    var $templatePath;
 
+   var $ignoreRepoPathPattern = array();
+
    // }}}
 
    // {{{ __construct($name, $svnName, $path, [$group, [$username, [$password]]])
@@ -1012,6 +1014,15 @@
 
    // }}}
 
+   // {{{ addIgnoreRepoPathPattern
+   //
+   // Set the ignore path pattern which works with the ParentPath function.
+   function addIgnoreRepoPathPattern($pattern)
+   {
+     $this->ignoreRepoPathPattern[] = $pattern;
+   }
+   // }}}
+
    // {{{ parentPath
    //
    // Automatically set up the repositories based on a parent path
@@ -1023,6 +1034,19 @@
          // For each file...
          while (false !== ($file = readdir($handle)))
          { 
+
+/* ignore some paths */
+$flag = false;
+foreach($this->ignoreRepoPathPattern as $reg) {
+  if ( preg_match($reg, $file) ) {
+    $flag = true;
+    break;
+  }
+}
+if ($flag) {
+  continue;
+}
+
             // That's also a non hidden directory
             if (is_dir($path.DIRECTORY_SEPARATOR.$file) && $file{0} != ".")
             {

さいごに

最新版の2.3系列のconfigclass.phpをみると、リポジトリの同一名での重複登録を省くための処理と、パターンにマッチした場合にのみリポジトリに加える処理も追加されていました。

addExcludedPath()を呼べばよさそうですが、指定するパスには絶対パスで指定する必要がありそうですが、現在では、Ubuntu 10.10の例にあるように、SVNディレクトリではないディレクトリがネガティブな影響を与える心配はないようです。

一括登録からみれば省くものは限られているケースが想定できて、想定外のパターンを含めてしまう可能性もあるので、バランスですが、一般的にはinclude, excludeは正規表現のパターンでも指定できたほうが便利だとは思いますが、とりあえずは古いWebSVNを動かしているが故のワークアラウンドでした。

2011/01/20

W3CのValidatorを通す方法 - Twitter/Facebookボタン編

W3CのMarkup Validation Service (validator.w3.org)はWebサイトの文書が規格に従っているかどうかを確認する事ができます。

有名なサイトであっても規格そのものに準拠していることは、このサービスを使えばあまり真剣に考えていないようすがみてとれます。

でもルールは守ってこそのルールですから、ここはどうにかしてValidatorサービスを通すようにしてみました。

今回使っている定義はXMTML/RDFaですが、XHTML系列であれば同じだと思います。

お題: Twitter/FacebookのボタンをWebサイトに追加する

作成した郵便番号の検索システムやらにTwitter/Facebookのボタンを追加しようとして、公式ガイドをみたところ見たことのないタグの使い方をしていました。

ちなみにボタンを追加するためのTwitterの公式ガイドは「Resources → Tweet Button」で、Facebookの方は「Like Button」にガイドがあります。

XHTMLではiframeタグが使えずにObjectタグを使うなどの変更が必要だったりすることは知られていますが、今回はその範囲を越えています。

スマートではないですが、規格に準拠しないところはJavaScriptを使って動的に作成することにしました。

Twitterボタンを設置する

オリジナルのコードは次のようになっています。

twitter.comに指示されたボタンを埋め込むためのHTML断片

<a 
  href="http://twitter.com/share"
  class="twitter-share-button" 
  data-count="vertical"
  data-via="YasuhiroABE">Tweet</a>
  <script type="text/javascript" src="http://platform.twitter.com/widgets.js"></script>
課題

問題はdata-countなどの属性が標準では定義されていないところです。

この部分をJavaScriptを使って追加してあげることにしました。

変更したコード

..
<script src="/js/jquery.min.js" type="text/javascript"></script>
<script src="http://platform.twitter.com/widgets.js" type="text/javascript" charset="utf-8"></script>
..
<script type="text/javascript"><!--
  jQuery(document).ready(function($){
    $("a.twitter-share-button").attr("data-url","http://www.yadiary.net/postal/");
    $("a.twitter-share-button").attr("data-text", "CouchDBを使用した郵便番号検索 @VPS");
    $("a.twitter-share-button").attr("data-via", "YasuhiroABE");
    $("a.twitter-share-button").attr("data-count", "horizontal");
    $("a.twitter-share-button").attr("data-lang", "ja");
  }
--></script>
...
<!-- twitter button --> 
<a href="http://twitter.com/share" class="twitter-share-button">Tweet</a> 
...

Facebookボタンを追加する

ボタンを追加する方法にはiframeタグを使う方法と、FBMLを使う方法が選択可能です。

TwitterボタンではjQueryを使用したので、JavaScriptを使ってみます。

facebookから提示されたボタンを埋め込むためのHTML断片

<script src="http://connect.facebook.net/en_US/all.js#xfbml=1"></script>
<fb:like href="http://www.yadiary.net/postal" show_faces="true" width="60"></fb:like>
課題

fbタグが未定義なので、xmlnsでnamespaceを追加しています。 これは実際に定義されている必要はないので、他にも使われているらしい"http://www.facebook.com/2008/fbml"を使いました。

修正した

<html xml:lang="ja"
  ...
  xmlns:fb="http://www.facebook.com/2008/fbml"
> 
<head>
...
  <script src="/js/jquery.min.js" type="text/javascript"></script> 
  <script type="text/javascript"><!--
  jQuery(document).ready(function($){
    /* for facebook button */
    var fbtag = document.createElement('fb:like');
    fbtag.setAttribute("href","http://www.yadiary.net/postal/");
    fbtag.setAttribute("layout", "button_count");
    fbtag.setAttribute("show_faces","true");
    fbtag.setAttribute("width", "80");
    document.getElementById("facebook-button").appendChild(fbtag);
  });
  --></script>
...
</head>
<body>
...
  <script type="text/javascript" src="http://connect.facebook.net/en_US/all.js#xfbml=1"></script> 
  <span id="facebook-button"></span> 
...
</body>
...

今回jQueryを使っているのは、「既に使っているから」という以外の理由はありません。 コード自体はjQueryには依存していないはずです。

まとめ

はたしてこの方法が妥当なのか微妙ですが、どちらのボタンもJavaScriptを有効にしていないと動かないので、タグをJavaScriptでレンダリングしても良いのかなぁと自分を納得させています。

なんにしても、こんな方法で標準に準拠しないようなタグを使うことも可能です。

結果としてValidatorサービスでエラーなしにする事ができて満足しています。

2011/01/19

StunnelのクライアントモードでCouchDBに接続する

CouchDB自体には通信を暗号化する機能が1.1系列からしか準備されていないので、stunnelを使っています。

普段は「CouchDB: Ruby CouchモジュールをDigest認証対応にする」ようにSSL接続に対応したクライアントを使っています。

とはいえCouchDB自体には、SSL接続に対応した機能がないので、CouchDB同士を接続する必要があるレプリケーション(Replication)を有効にするためにStunnelのクライアント機能を使ってみました。

実際にはCouchDBの間にはインターネットがありますが、おおまかなシステム構成図は次のとおりです。

Stunnel Client: System Overview

Stunnelサーバ設定の確認

CouchDBの起動時にdefault/couchdbファイルに書かれているstunnelを起動するコマンドラインは次のようになっています。

/usr/bin/stunnel -v 3 -a /usr/local/etc/couchdb/sslcerts -d :::6984 -r 127.0.0.1:5984

-aオプションに指定しているディレクトリの中は次のような感じです。

$ sudo ls -l /usr/local/etc/couchdb/sslcerts
lrwxrwxrwx 1 root couchdb   17 Dec  2 11:44 22f12cbd.0 -> demoCA.cacert.pem
lrwxrwxrwx 1 root couchdb   23 Dec  2 11:44 6b0ab199.0 -> stunnel.client.cert.pem
-rw-r----- 1 root root    3664 Dec  2 10:23 demoCA.cacert.pem
-rw-r--r-- 1 root couchdb 3494 Dec  2 11:34 stunnel.client.cert.pem

Stunnelクライアントの設定

サーバ側はSSLクライアント認証が有効なので、普通に接続しようとすると失敗します。

接続用Certificateファイルの作成

いつも通りにCA.plを使って、newcert.pem,newkey.pemファイルを作成します。

CA.plへのパスはUbuntu 10.04 LTSでのものです。環境毎に格納場所が違いますので、locate CA.plで探すか、手動でopensslを実行してください。

$ /usr/lib/ssl/misc/CA.pl -newreq
$ /usr/lib/ssl/misc/CA.pl -sign
$ rm newreq.pem
$ cp newcert.pem couchdb.client.cert.pem
$ cp newkey.pem  couchdb.client.key.pem
$ openssl rsa < couchdb.client.key.pem > couchdb.client.nokey.pem
$ cat couchdb.client.cert.pem couchdb.client.nokey.pem > couchdb.client.pem

最終的にはcouchdb.client.pemファイルを使い、stunnelをクライアント化します。

Stunnelサーバ側でのCertificateファイルの更新

最初のコマンドラインにあるようにStunnelサーバは接続に使うcertificateを/usr/local/etc/couchdb/sslcertsに保存しています。

今回作成したcouchdb.client.cert.pemファイルと"CA.pl -sign"の実行時に使ったCAのcacert.pemファイルをstunnelサーバ側に転送しておきます。

Stunnelサーバ側で次のような操作をしておきます。 cacert.pemが既に存在していて、内容が同じであれば省いてください。 内容が違う場合はファイル名を変更してコピーしておく必要があります。

$ sudo cp couchdb.client.cert.pem cacert.pem  /usr/local/etc/couchdb/sslcerts
$ sudo c_rehash  /usr/local/etc/couchdb/sslcerts
Stunnelクライアントモードでの起動

基本的には次のようなコマンドラインでStunnelサーバに接続します。

/usr/bin/stunnel -c -p /usr/local/etc/couchdb/sslcerts/couchdb.client.pem" -d 127.0.0.1:5985 -r 192.168.x.x:6984

ここでの192.168.x.xはStunnelサーバのIPアドレスです。

Stunnelクライアント側にはcouchdb.client.pemファイルをコピーしておき、やはり次のようなコマンドを実行します。

$ sudo cp couchdb.client.pem /usr/local/etc/couchdb/sslcerts/
$ sudo /usr/bin/stunnel -c -p /usr/local/etc/couchdb/sslcerts/couchdb.client.pem" -d 127.0.0.1:5985 -r 192.168.x.x:6984
$ curl -u admin:xxxxxx http://localhost/:5985/_all_dbs

サーバ側はBasic認証が有効になっているのでadmin:xxxxxsはID(admin)とパスワード(xxxxxx)を':'(コロン)で区切って指定しています。

セキュリティ上の考察

Stunnelクライアントからはログインできるユーザは全てサーバに到達する事が可能になります。

もちろんパスワードがわからなければ接続できませんが、curlコマンドラインを起動する場合にはps auxwwwの出力にはでないですが、bash等、使っているシェルのhistoryには記録されます。

それが気になることはあまりないとは思いますが、こういうところにも気を配る必要があるかないか、環境はちゃんと理解しておくことが必要です。

さいごに

とりあえず、ここまでで無事にレプリケーションを有効にする準備ができました。

やっぱりセキュリティ周りの作りはちょっと不安なんですよね。

2011/01/14

Rubyでのflockの使い方

マニュアルでflockの使い方をみると、File::RDWR|File::CREATを指定していて、"w"を使うと切り詰め(truncate)るからダメだという記述があります。

ruby-1.9.2-p0/file.cに記載されているflockのサンプル

 *     # update a counter using write lock
 *     # don't use "w" because it truncates the file before lock.
 *     File.open("counter", File::RDWR|File::CREAT, 0644) {|f|
 *       f.flock(File::LOCK_EX)
 *       value = f.read.to_i + 1
 *       f.rewind
 *       f.write("#{value}\n")
 *       f.flush
 *       f.truncate(f.pos)
 *     }

たしかに"w"はだめだろうけど"r+"か"w+"ならいけるんじゃなかろうかと思って調べてみました。

ソースコードに記述された各モード文字列の意味

手元にあったruby-1.9.2-p0のio.cをみると、次のような記述があります。

ruby-1.9.2-p0/io.cからの抜粋 (9755-9773行)

 *    Mode |  Meaning
 *    -----+--------------------------------------------------------
 *    "r"  |  Read-only, starts at beginning of file  (default mode).
 *    -----+--------------------------------------------------------
 *    "r+" |  Read-write, starts at beginning of file.
 *    -----+--------------------------------------------------------
 *    "w"  |  Write-only, truncates existing file
 *         |  to zero length or creates a new file for writing.
 *    -----+--------------------------------------------------------
 *    "w+" |  Read-write, truncates existing file to zero length
 *         |  or creates a new file for reading and writing.
 *    -----+--------------------------------------------------------
 *    "a"  |  Write-only, starts at end of file if file exists,
 *         |  otherwise creates a new file for writing.
 *    -----+--------------------------------------------------------
 *    "a+" |  Read-write, starts at end of file if file exists,
 *         |  otherwise creates a new file for reading and
 *         |  writing.
 *    -----+--------------------------------------------------------

"w"や"w+"を使っていると、もしロックに失敗してもファイルの内容が失なわれてしまいます。 もっともLOCK_NBを加えていないサンプルのコードの場合には、ずっと待つので関係ない気がします。

そこでサンプルを考えてみました。2つのスクリプトがtest00.txtというファイルに自分のファイル名を書き込もうとします。

最初に実行するtest00a.rb

#!/usr/local/bin/ruby
#-*- coding: utf-8 -*-
@basedir = File::dirname($0)
file = File::join([@basedir,"test00.txt"])
File.open(file, "w", 0644) {|f|
  f.flock(File::LOCK_EX)
  f.rewind
  f.write($0)
  f.flush
sleep 10
  f.truncate(f.pos)
}

次に実行するtest00b.rb

#!/usr/local/bin/ruby
#-*- coding: utf-8 -*-
@basedir = File::dirname($0)
file = File::join([@basedir,"test00.txt"])
File.open(file, "w", 0644) {|f|
  if f.flock(File::LOCK_EX)
    f.rewind
    f.write($0)
    f.flush
    f.truncate(f.pos)
  end
}

別端末でcat test00.txtをループしながら、スクリプトを実行すると2つめの"test00b.rb"を実行した時点で処理が一時停止したかのようにみえます。

$ while true ; do cat test00.txt ; sleep 1 ;done

...
./test00a.rb
./test00a.rb
./test00a.rb  ## ← test00b.rbの実行直後から、内容のないファイルをcatするため画面には何も表示されない
./test00b.rb  ## ← seep 10の処理が終り、ファイルが上書きされ、その内容が出力される
./test00b.rb
./test00b.rb
...

とはいえ、確実にflockで待機していたtest00b.rbが内容を上書きしているので、意図したような動き自体にはなっているはずです。

ここでLOCK_NBを一緒に使う場合を考えると、実際にはファイルを上書きしなくてもファイルサイズが零になるため問題になるでしょう。

スクリプトを少し変更して、File::LOCK_NBを一緒に使うようなサンプルを作成してみます。

File::LOCK_NBを使った排他制御の例

前節と同様にtest01a.rb, test01b.rbを準備して、それぞれからtest01.txtを自身のファイル名で上書きする事を考えます。

ただし今回はファイルオープンに"r+"オプションを指定します。

最初に実行するtest01a.rb

#!/usr/local/bin/ruby
#-*- coding: utf-8 -*-
@basedir = File::dirname($0)
file = File::join([@basedir,"test01.txt"])
File.open(file, "r+", 0644) {|f|
  f.flock(File::LOCK_EX)
  f.rewind
  f.write($0)
  f.flush
sleep 30
  f.truncate(f.pos)
}

次に実行するtest01b.rb

#!/usr/local/bin/ruby
#-*- coding: utf-8 -*-
@basedir = File::dirname($0)
file = File::join([@basedir,"test01.txt"])
File.open(file, "r+", 0644) {|f|
  if f.flock(File::LOCK_EX|File::LOCK_NB)
    f.rewind
    f.write($0)
    f.flush
    f.truncate(f.pos)
  end
}

これで実行するとファイルの新規作成はできませんが、既存ファイルを準備しておけば期待通りに動きます。

さいごに

表に戻ると、C言語の最初にfopenを習った時は"r"やら"r+"やらのアクセスモードの違いがよく分かりませんでした。

でも確認すればいいんですよね。ただ、経験がない分、それをどういう場面で使えばいいかの想像力が少し十分ではなかったかな。

教えるっていう行為は経験値が足りない人にどう伝えればいいかの部分が難しいんですよね、きっと。

2011/01/09

IPSetを使ったFirewallルールの設定について

以前家のブロードバンドルータにしているAlixにIPSetを導入するところまでを、「ブロードバンドルータにしているalixをDebian squeezeにして、ipsetを導入する」に投稿していました。

しばらく前にいわゆるボットネットの一部といわれているIPとの接続を弾くためにiptablesルールを設定仕直したので、そのログをまとめておきます。

現状のiptablesの設定状況

IPSetの導入と平行してiptablesルールの見直しを行なって、起動時にはiptables-restoreを使ってルールを設定しています。

設定のメインは/etc/network/if-pre-up.d/iptablesスクリプトで、if-pre-up.dディレクトリにあるスクリプトは起動時などifupによってネットワークデバイスが設定される直前に実行されます。

本当は重複して実行される可能性のある/etc/network/if-*.dに配置するのはスマートじゃないけど、iptables/ipsetの設定はNICが認識されているかどうかに無関係に実行できるので、重複起動は気にしないことにしました。

他には/etc/rc2.d/なんかにスクリプトを配置することもできると思います。 ここら辺はdebianで標準的な場所がないので、ホストの使い方によって変化するかもしれません。

もしWorkstationならネットワークデバイスの初期化云々はそれほど重要ではないので、/etc/rc.local辺りで設定をするかもしれません。

常時接続のルータは意図せずiptables設定なしにネットワークに接続するのは嫌なので、if-pre-up.dを使いました。

iptables設定スクリプトと参照するファイル群

このスクリプトの配置場所はいくつかの候補の中から選択するしかありませんが、ファイル名と内容は自由に書けるので、内部でiptables-restoreコマンド等を実行するようにしました。

スクリプトの中身は次のようになっています。

/etc/network/if-pre-up.d/iptablesファイル全体

#!/bin/bash

PATH=/sbin:/usr/sbin
BASEDIR="$(dirname $0)"

ipset --restore < "${BASEDIR}/../ipset.restored"
iptables-restore < "${BASEDIR}/../iptables.restored"
ip6tables-restore < "${BASEDIR}/../ip6tables.restored"

スクリプトでは/etc/network/直下に3つのファイルがある事を前提にしています。

  • /etc/network/ipset.restored
  • /etc/network/iptables.restored
  • /etc/network/ip6tables.restored

今回はip6tablesは範囲外です。ipsetとiptablesの連携について書いていきます。

iptablesとの連携

直接"iptables.restored"ファイルを書く事もできますが、いろいろ危険なのでiptablesコマンドを使って設定した後に"iptables.restored"ファイルを保存しています。

ipsetとの連携は、まずiptables側で空のipsetルールを作成して、それを参照するiptablesルールを加えます。

ipset,iptablesコマンドを使った空のipsetルールを使ったルール作り

#!/bin/bash
PATH=/sbin:/usr/sbin:/bin:/usr/bin

## delete all rules
iptables -F
iptables -t nat -F
iptables -X
iptables -t nat -X
iptables -Z
ipset -F
ipset -X
...
ipset -N denyip iphash --hashsize 36864
ipset -N denynet nethash --hashsize 36864
iptables -A INPUT -m set --match-set denyip src -j DROP
iptables -A INPUT -m set --match-set denynet src -j DROP
iptables -A FORWARD -m set --match-set denyip src -j DROP
iptables -A FORWARD -m set --match-set denynet src -j DROP
iptables -A OUTPUT -m set --match-set denyip dst -j DROP
iptables -A OUTPUT -m set --match-set denynet dst -j DROP
iptables -A FORWARD -m set --match-set denyip dst -j DROP
iptables -A FORWARD -m set --match-set denynet dst -j DROP
...

このスクリプトを実行して、動きに問題がない事を確認してからiptables.restoredファイルを作成しておきます。

$ sudo /sbin/iptables-save > /etc/network/iptables.restored

次にipsetを使い、定義だけされている空のipsetルールに具体的な設定を加えていきます。

まず用意するのはボットネットに組み込まれていると思われるIPアドレスのリスト。

iptables.deny.outboundファイル抜粋

##
## comment string, this line should begin with the '#' char.
109.10x.23x.1xx
...
109.23x.22x.0/24
...

このファイルを処理する

#!/bin/bash
PATH=/sbin:/usr/sbin:/bin:/usr/bin
BASEDIR="$(dirname $0)"
OUTBOUND_BLOCK_FILE="${BASEDIR}/iptables.deny.outbound"

ipset -N tmpip iphash --hashsize 36864
ipset -N tmpnet nethash --hashsize 36864
if test -f "${OUTBOUND_BLOCK_FILE}"; then
  while read ipaddr
  do
    ( echo ${ipaddr} | egrep ^# > /dev/null ) && continue
    ip="$(echo ${ipaddr}|awk -F/ '{print $1}')"
    mask="$(echo ${ipaddr}|awk -F/ '{print $2}')"
    if test "${ip}" != "" -a "${mask}" = "" ; then
      ipset -A tmpip "${ipaddr}"
    else
      ipset -A tmpnet "${ipaddr}"
    fi
  done < "${OUTBOUND_BLOCK_FILE}"
fi
ipset -W denyip tmpip
ipset -W denynet tmpnet
ipset -X tmpip
ipset -X tmpnet

設定したipsetルールは--saveオプションを使って保存します。

$ sudo ipset --save > etc/network/iptables.restored

こういう形でスクリプトを分けたのにはいくつか理由がありますが、ipset/iptablesコマンドを利用して大量のIPアドレスを処理するルールを追加するのにはかなり時間がかかるので処理を分けたかったのが大きな理由です。

次はipsetを使ってみたかったということ。

iptablesを使う事も可能ですが、空のipsetルールを設定したように、空のchainを作成しておいて、後からそのchainに個別のIPアドレスをDROPするルールを追加していくのが良いでしょう。

そうしないと一連のiptablesコマンドの実行が完了するまでの間は、ネットワークに対して脆弱なまま接続する事になるかもしれません。

もちろん直接iptables-restoreコマンドが解釈するようなファイルを生成して時間を短縮することは可能です。

いずれの方法でも、テストを十分にして、いきなり起動時に読み込まれるファイルとして保存しない事が重要です。

ここでリストの挙げられているIPアドレスからの接続を拒否することよりも、そういったサイトへの接続を拒否する事の方が重要です。 もっとも接続してはいけないマスター系ノードのIPアドレスは変化するでしょうし、知られていないものがあるでしょうから、本当にこういうリストが有用なのかは少し疑問が残ります。

何もしないよりはましかな。

CouchDB: Viewでのkeyの並び順(Order)の確認レシピ

CouchDBでViewを作成して、startkeyendkeyで条件を指定する時に、優先順位がいまいち分かりずらいので検証するための環境を作ってみました。

あらかじめ準備しておくものは次のものです。

  • CouchDB本体 (今回は1.0.1を準備しました)
  • Ruby (今回はjsonライブラリが用意されているRuby1.9を使います。Ruby 1.8を使用する場合にはjsonライブラリを別途御準備ください。curlでの代用も可能ですが、十分な注意が必要です)
  • テスト文書作成用DB (今回は"example"を使用しますが、任意の名前で結構です)

併わせて参考のために本家のCouchDB Wiki - View_collationを確認すると良いでしょう。

とりあえず結論

結果だけを知りたいという方のために、最初に今回の結果を載せておきます。

とりあえず"id"は無視して、keyの右辺の並びを上から順に眺めてください。

/example/_design/order/_view/orderの表示結果

{"id"=>"ordercheck.12", "key"=>nil, "value"=>nil}
{"id"=>"ordercheck.2", "key"=>false, "value"=>nil}
{"id"=>"ordercheck.4", "key"=>true, "value"=>nil}
{"id"=>"ordercheck.14", "key"=>-1, "value"=>nil}
{"id"=>"ordercheck.5", "key"=>1, "value"=>nil}
{"id"=>"ordercheck.3", "key"=>10, "value"=>nil}
{"id"=>"ordercheck.11", "key"=>"", "value"=>nil}
{"id"=>"ordercheck.0", "key"=>"a", "value"=>nil}
{"id"=>"ordercheck.15", "key"=>"bcd", "value"=>nil}
{"id"=>"ordercheck.10", "key"=>"z", "value"=>nil}
{"id"=>"ordercheck.13", "key"=>"\uFFF0", "value"=>nil}
{"id"=>"ordercheck.6", "key"=>[0, 1], "value"=>nil}
{"id"=>"ordercheck.9", "key"=>[0, 3, 2], "value"=>nil}
{"id"=>"ordercheck.7", "key"=>[1], "value"=>nil}
{"id"=>"ordercheck.8", "key"=>[1, nil, ""], "value"=>nil}
{"id"=>"ordercheck.16", "key"=>[1, 2], "value"=>nil}
{"id"=>"ordercheck.1", "key"=>{}, "value"=>nil}

nilからfalse,trueの順に並んでいく様子がわかります。

nil → 真偽値(false→true) → 数値 → 文字列 → 配列 → ハッシュ

配列の場合は基本的に先頭から要素の有無でまずソートされ、その次に要素の値でソートされています。 要素の数は重要ではない事がわかります。

この配列の扱いは個人的にViewの定義を考える時に混乱するところですが、Viewがちゃんとソートされていればlimit, skipを使って部分的な結果を得て、そのままWebページなりエンドユーザに出力することが出来るので便利なはずです。

作業の流れ

今回はCouchDB内に実際に文書とViewを作成します。その結果を表示する事で、どういった順序でソートされるのかを確認します。

作成する文書

文書の構造は次の通りです。

{
  "_id":"check_order.11",
  "_rev":"1-77356980318a930bb8afc1e6193fa981",
  "k":""
}

"k"に真偽値やら数値やらを代入していきます。

Map関数

"k"をキーにしています。Reduce関数は定義していません。

function(doc) {
  if(doc._id.indexOf('check_order.') == 0) {
    emit(doc.k, null);
  }
}
作成したViewの表示

最終的には最初に載せたような結果が得られ、優先順位は次のようになっている事がわかります。

nil → 真偽値(false→true) → 数値 → 文字列 → 配列 → ハッシュ

今回はこういう結果を出力するスクリプトを準備しておいて、どういう並び順になるか確認するための環境を作ります。

スクリプトの準備

流れに従って、文書作成用のスクリプトを作成する前にCouchモジュールに対するWrapperモジュールを作成しておきます。

ディレクトリ・ファイル構造

今回は"test"ディレクトリをトップディレクトリとして、相対的にlib, initdb, viewsディレクトリを作成していきます。libディレクトリ名は固定で、各スクリプトから"../lib"にパスを通します。

"lib"ディレクトリと同じレベルに存在すれば、"initdb", "views"ディレクトリ名は任意の名前に変更できます。

  • test/lib … ライブラリディレクトリ ("lib"ディレクトリ名は変更不可)
  • test/lib/couchdb.rb … CouchDB Wikiに掲載されているCouchモジュール
  • test/lib/util.rb … Couchモジュールにエラー処理を追加したWrapperモジュール
  • test/initdb … 文書作成用ディレクトリ (ディレクトリ名は変更可)
  • test/initdb/init_docs.rb … 文書を作成するスクリプト
  • test/initdb/show_all_docs.rb … 作成されている文書を全て表示するスクリプト
  • test/initdb/remove_docs.rb … 任意の_idを持つ文書を削除するスクリプト
  • test/views … View作成用ディレクトリ (ディレクトリ名は変更可)
  • test/views/_design.views.order.rb … Viewを作成するスクリプト
  • test/views/show_views.rb … 作成したViewを表示するスクリプト
lib/util.rbの作成

require 'couchdb'で呼び出しているライブラリは、Couch Wikiの「Getting started with Ruby」に掲載されているCouchモジュールです。

先頭にあるDBNameには文書を作成するために使用する、作成済みDB名を'/'から始めて書いてください。

次にYaCouch::getCouchの中を適宜変更して、Couch::Serverクラスのインスタンスをcouchに代入できるようにオプションを適宜変更します。

util.rbファイル全体

# -*- coding: utf-8 -*-

require 'json'
require 'uri'
require 'couchdb'

module YaCouch
  DBname = '/example'
  def YaCouch::getCouch
    ## couch = Couch::Server.new('user'=>'admin', 'password'=>'')
    couch = YaCouch::Main::getCouchAsAdmin
    return YaCouch::Main.new(couch)
  end
  class Main
    require 'json'
    require 'uri'
    def initialize(couch = nil, debug = false)
      @couch = couch
      @debug = debug
    end
    def get(uri)
      json = Hash.new
      begin
        res = @couch.get(URI.escape(uri))
        json = JSON.parse(res.body)
      rescue
        p $! if @debug
      end
      json = Hash.new if json.has_key?("error")
      json
    end
    def put(uri, json)
      res = nil
      begin
        res = @couch.put(URI.escape(uri), json.to_json)
      rescue
        p $! if @debug
      end
      res
    end
    def post(uri, json)
      res = nil
      begin
        res = @couch.post(URI.escape(uri), json.to_json)
      rescue
        p $! if @debug
      end
      res
    end
    def delete(uri)
      res = nil
      begin
        res = @couch.delete(URI.escape(uri))
      rescue
        p $! if @debug
      end
      res
    end
  end
end
initdb/init_docs.rbの作成

文書名(_id)は、「"check_order." + 数字」にしていますが、何でも構いません。

init_docs.rbファイル全体

#!/usr/bin/env ruby

$:.unshift File.join([File.dirname($0), "..", "lib"])
require 'util'

@couch = YaCouch::getCouch
@num = 0
def up(json_value)
  uri = YaCouch::DBname + '/check_order.' + @num.to_s
  json = @couch.get(uri)
  json["k"] = json_value
  res = @couch.put(uri, json)
  @num += 1
end

## prepare documents
up("a")
up(Hash.new)
up(false)
up(10)
up(true)
up(1)
up([0,1])
up([1])
up([1,nil,""])
up([0,3,2])
up("z")
up("")
up(nil)
up("\ufff0")
up(-1)
up([0,4])
up("bcd")
up([1,2])
views/_design.views.order.rbの作成

Viewを作成するポイントは "/example/_design/order" です。

_design.views.order.rbファイル全体

#!/usr/bin/env ruby

$:.unshift File.join([File.dirname($0), "..", "lib"])
require 'util'

@couch = YaCouch::getCouch
uri = YaCouch::DBname + "/_design/order"
json = @couch.get(uri)
json['language'] = 'javascript'
json['views'] = Hash.new
json['views']['order'] = Hash.new
json['views']['order']['map'] = <<-MAP
function(doc) {
  if(doc._id.indexOf('check_order.') == 0) {
    emit(doc.k,null);
  }
}
MAP
res = @couch.put(uri,json)
p res.body
views/show_views.rb

show_views.rbファイル全体

#!/usr/local/bin/ruby

$:.unshift File.join([File.dirname($0), "..", "lib"])
require 'util'
@couch = YaCouch::getCouch

uri = YaCouch::DBname + "/_design/order/_view/order"
json = @couch.get(uri)
json['rows'].each do |row|
  p row
end

このスクリプトの実行結果は、最初に掲載したようなdoc.kをキーとしてソートされた文書のリストになります。

まとめ

タイトルを「〜レシピ」にしたので、その体裁で書こうと思ったものの、挫折しました。

それはさておき、Rubyで使えるライブラリはいろいろありますが、手元の環境ではStunnel4を使い、CouchDBサーバはSSLクライアント認証を有効にしているため、接続部分をカスタマイズする必要があります。

テストのためにApacheのmod_proxyを使ってDigest認証での接続も出きるようにしていますが、いずれにしてもデフォルトの接続処理のセキュリティに満足していないので、低レベルなCouchモジュールに手を入れて使っています。

そんな事をしていないのであれば他のライブラリに慣れるのが良さそうですが、その場合でもこのスクリプトを大きく変更する必要はないと思います。

Appendix. 追加スクリプト

処理の本筋ではない、initdb/show_all_docs.rb と initdb/remove_docs.rb スクリプトを掲載しておきます。

initdb/show_all_docs.rb

show_all_docs.rbファイル全体

#!/usr/bin/env ruby

$:.unshift File.join([File.dirname($0), "..", "lib"])
require 'util'
@couch = YaCouch::getCouch
uri = YaCouch::DBname + '/_all_docs?include_docs=true'
json = @couch.get(uri)
json['rows'].each do |row|
  p row['doc']
end
initdb/remove_docs.rb

引数無しに実行すると、内部でdelete_doc_prefix変数に設定されている"_id"名が"check_order."で始まる文書が削除されます。

View定義を削除する時には引数に "_design"を指定してください。

remove_docs.rbファイル全体

#!/usr/bin/env ruby
$:.unshift File.join([File.dirname($0), "..", "lib"])
require 'util'

delete_doc_prefix ='check_order.'
delete_doc_prefix = ARGV[0] if ARGV.length == 1

@couch = YaCouch::getCouch
uri = YaCouch::DBname + '/_all_docs?include_docs=true'
json = @couch.get(uri)
json['rows'].each do |row|
  d = row['doc']
  if d['_id'] =~ /^#{delete_doc_prefix}/
    uri = format("%s/%s?rev=%s", YaCouch::DBname, d['_id'], d['_rev'])
    res = @couch.delete(uri)
    p res.body
  end
end

2011/01/07

DTIのVPSサービスで、Default: DROPなiptablesの設定をしてみた

OpenVZを使っているDTIのVPSサービスでは、各VM内からはカーネルモジュールを動的に読み込む事ができません。

あらかじめ準備されたカーネルにはip6tablesが使うモジュールが設定されていないために、コマンドを実行することができない事に今日になって気がつきました。

IPv6対応を謳うサービスで、サーバ用インスタンスを提供しているのであれば、セキュリティを強化するための手段が一つない事になるので、少しがっかりしました。

さて気をとりなおして、IPv4用にiptablesの設定をしたので、その作業メモです。

IPv4用にiptablesを設定する

特別な設定はしませんが、INPUT/OUTPUT/FORWARDのデフォルトルールをDROPにした上でルールを設定しています。

iptablesのログをCouchDBに入れて郵便番号情報と似たようなインタフェースで検索できるようにしていますが、サンプルで動かしている自宅の自作ブロードバンドルータのログをいろいろみていると22番ポート(ssh)と445番ポート(microsoft-ds/CIFS)へのアクセスが非常に多いのがわかります。

あとはDDoSのためにIPアドレスを詐称したパケットが捏造されているのか、ICMPパケットもありましたし、いきなりACK,SYNが送られてきているケースもあるようでした。

いくらポートを閉じているとはいえ、いろいろ迷惑をかけるのもあれなので、VPSサーバでもiptablesを設定して、積極的にDROPするようにしています。

iptables設定用スクリプト全体

#!/bin/bash

umask 022
PATH=/usr/sbin:/sbin:$PATH

## reset all settings
iptables -F
iptables -Z
iptables -X

## default rule is DROP, but all unexpected connection will be logged later.
iptables -P FORWARD DROP
iptables -P INPUT DROP
iptables -P OUTPUT DROP

## define logging rules
iptables -N loaccept
iptables -A loaccept -m limit --limit 30/minute -j LOG --log-prefix "fw local accept "
iptables -A loaccept -j ACCEPT
iptables -N fwaccept
iptables -A fwaccept -m limit --limit 30/minute -j LOG --log-prefix "fw forward accept "
iptables -A fwaccept -j ACCEPT
iptables -N fwdrop
iptables -A fwdrop -m limit --limit 30/minute -j LOG --log-prefix "fw forward drop "
iptables -A fwdrop -j DROP
iptables -N inaccept
iptables -A inaccept -m limit --limit 30/minute -j LOG --log-prefix "fw input accept "
iptables -A inaccept -j ACCEPT
iptables -N indrop
iptables -A indrop -m limit --limit 30/minute -j LOG --log-prefix "fw input drop "
iptables -A indrop -j DROP
iptables -N outaccept
iptables -A outaccept -m limit --limit 30/minute -j LOG --log-prefix "fw output accept "
iptables -A outaccept -j ACCEPT
iptables -N outdrop
iptables -A outdrop -m limit --limit 30/minute -j LOG --log-prefix "fw output drop "
iptables -A outdrop -j DROP

## allow all loopback connection
iptables -A INPUT  -i lo -j loaccept
iptables -A OUTPUT -o lo -j loaccept

## logging all connection
iptables -A INPUT -p icmp --icmp-type echo-request -j inaccept
iptables -A INPUT -p icmp --icmp-type redirect -j indrop
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -A INPUT -m state --state NEW -p tcp --dport 4949 -j inaccept  ## for munin
iptables -A INPUT -m state --state NEW -p tcp --dport 443 -j inaccept   ## for web
iptables -A INPUT -m state --state NEW -p tcp --dport 80 -j inaccept    ## for web
iptables -A INPUT -m state --state NEW -p tcp --dport 23 -j inaccept    ## for ssh
iptables -A INPUT -m state --state NEW -p tcp --dport 25 -j inaccept    ## for smtp
iptables -A INPUT -m state --state NEW -p tcp --dport 53 -j inaccept    ## for dns
iptables -A INPUT -m state --state NEW -p udp --dport 53 -j inaccept    ## for dns
iptables -A INPUT -j indrop
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -A OUTPUT -m state --state NEW -j outaccept
iptables -A OUTPUT -j outdrop
iptables -A FORWARD -j fwdrop

## uncomment this after checking above settings.
## iptables-save > /etc/network/iptables.restored

大抵の方はSSH用ポートが変更されているので、ここで"23"と設定しているSSH用のポート番号は3843番などに適宜変更する必要がありそうです。

一度スクリプトを実行して動作を確認してから問題がなければ、再起動後も適用されるように最後のコメントアウトを外すか、手動でiptables-saveを実行して、/etc/network/iptables.restoredファイルを更新しておきます。

そして、作成したファイルが起動時に読み込まれるように/etc/network/if-up.d/iptablesファイルを作成しておきます。

#!/bin/bash

BASEDIR="$(dirname $0)"
iptables-restore < "${BASEDIR}/../iptables.restored"

最後に実行権限を与えておきます。

$ sudo chmod 700 /etc/network/if-up.d/iptables

これでIPv4用の設定は以上で、ログの内容は /var/log/kern.log に書き出されます。

syslogの機能をフルに使うのであれば、 -j LOG行で--log-level debugなどの設定をポリシーに従って設定して、facilityを分けたり適切なメッセージを送信する事もできると思います。