仙石浩明の日記

プログラミングと開発環境

2008年5月12日

x86_64 な Linux カーネルで i386 プログラムを実行するときの注意点 ── ivtv ドライバの ioctl インタフェース

64bit Linux (x86_64 別名 amd64) は、 CONFIG_IA32_EMULATION を有効にしておくことにより、 32bit プログラム (i386 別名 ia32) を走らせることができる。 したがって 64bit へ移行する際は、 全プログラムを一度に 64bit 化する必要はなく、 まずカーネルだけ 64bit 化しておいて、 各プログラムは (バージョンアップの機会などに) 徐々に 64bit 化していけばよい。 ただし 32bit プログラムがカーネルの機能を呼び出す場合は、 各機能それぞれが 32bit プログラムからの呼び出しに対応していることが前提となる。

32bit プログラムからの呼び出しに対応するといっても、 基本的には引数の型を変換するだけである。 x86_64 の整数データモデルは LP64、 つまり long int 型とポインタ型が 64bit で (引数の型として多用される) int型は 32bit のままなので、 変換が不要なケースも多い。

例えば ioctl システムコールはファイル・ディスクリプタ (file descriptor, 以下 fd と略記) ごとに カーネルが実行すべき機能は変わってくるわけで、 その実装は各デバイス・ドライバに委ねられることが多い。 したがって 32bit プログラムからの ioctl 呼び出しに応えられるか否かは、 各ドライバが 32bit 対応しているか否かに依存する。 不幸にしてドライバが対応していない場合は、

ioctl32(tv:11028): Unknown cmd fd(5) cmd(40045613){t:'V';sz:4} arg(081ec8b4) on /dev/video0

などといったカーネル・メッセージ (dmesg) が出力される。 このメッセージは、 カーネル・ソース中 fs/compat_ioctl.c の compat_ioctl_error が出力している:

static void compat_ioctl_error(struct file *filp, unsigned int fd,
    unsigned int cmd, unsigned long arg)
{
    ...
    compat_printk("ioctl32(%s:%d): Unknown cmd fd(%d) "
            "cmd(%08x){t:%s;sz:%u} arg(%08x) on %s\n",
            current->comm, current->pid,
            (int)fd, (unsigned int)cmd, buf,
            (cmd >> _IOC_SIZESHIFT) & _IOC_SIZEMASK,
            (unsigned int)arg, fn);
    ...
}

fs/compat_ioctl.c は 32bit 版 ioctl システムコールを実装していて、 32bit プログラムが ioctl システムコールを呼び出すと、 この中の compat_sys_ioctl ルーチンが呼ばれる:

asmlinkage long compat_sys_ioctl(unsigned int fd, unsigned int cmd,
                unsigned long arg)
{
    ...
        if (filp->f_op && filp->f_op->compat_ioctl) {
            error = filp->f_op->compat_ioctl(filp, cmd, arg);
            if (error != -ENOIOCTLCMD)
                goto out_fput;
        }
    ...
            compat_ioctl_error(filp, fd, cmd, arg);
    ...
 out_fput:
    fput_light(filp, fput_needed);
 out:
    return error;
}

つまりドライバ側で file 構造体の compat_ioctl 関数ポインタ (filp->f_op->compat_ioctl) が定義されていればそれが呼ばれ、 未定義ならば上記のような「Unknown cmd」エラーが出力される。

ちなみにこのエラーメッセージの「tv:11028」は、 ioctl を呼び出した 32bit プロセスの名前 (コマンド名) とプロセスID であり、 fd(5), cmd(40045613), arg(081ec8b4) は、 それぞれ ioctl システムコールの第一 (つまり fd 番号)、 第二 (ioctl リクエスト番号)、第三引数 (ioctl リクエストの引数) であり、 最後の on /dev/video0 は (第一引数の) fd 番号に対応するファイルのパス名である。

そして、この tv コマンドは 「ビデオキャプチャ・カード GV-MVP/RX2W を使って Linux 2.6.24.4 でテレビ録画」で紹介した perl スクリプト であり、 その名称から推測できるとおりテレビ録画を行なうためのスクリプトである。

このスクリプトでは Video::ivtv モジュールを利用していて、 このモジュールが /dev/video0 つまり TV キャプチャ・デバイスに対して、 ioctl システムコールを呼び出している。 上記エラーはスクリプト中 $IvTV->stopEncoding($TunerFD); を実行したときに発生した。

その名称から推測できる通り、 stopEncoding メソッドはキャプチャ・デバイスに対して エンコーディングの停止を指示するためのもので、 内部で ioctl(fd, VIDIOC_STREAMOFF) などと ioctl 呼び出しを行なっている。 VIDIOC_STREAMOFF は videodev2.h にて、

#define VIDIOC_STREAMOFF    _IOW  ('V', 19, int)

と定義されていて、このマクロを展開すると 40045613 (16進) となり、 上記カーネル・メッセージ「cmd(40045613)」と一致する。

というわけで、(少なくとも Linux 2.6.24.7 に含まれる) ivtv ドライバは、 残念ながら 32bit 対応していないことが分かった。 もちろん x86_64 なカーネルではなく、 i386 カーネルを使えば 32bit プログラムから ivtv ドライバを使うことができるが、 x86_64 なカーネルでは、32bit プログラムからの ioctl システムコールを 64bit カーネルが受付けられる形に変換できないということだ。

とはいうものの、 32bit だろうが 64bit だろうが ioctl のインタフェースに大した変わりはないはずだ。 どうして ivtv ドライバは 32bit 呼び出しをサポートしていないのだろう? と 思いながらドライバのソースを眺めていると... drivers/media/video/compat_ioctl32.c を見つけた。 名前からしていかにも 32bit 版 ioctl のように見える。

compat_ioctl32.c の中の v4l_compat_ioctl32 ルーチンは、 32bit な ioctl 呼び出しを受付けて 引数を 64bit へ変換し (といっても int 型はどちらも 32bit だが)、 本来の (64bit な) ioctl を呼び出し直す仕組みになっている。 なぜ ivtv ドライバは、このルーチンを利用していないのだろうか。

static int do_video_ioctl(struct file *file, unsigned int cmd, unsigned long arg)
{
    ...
    /* First, convert the command. */
    switch(cmd) {
        ...
    case VIDIOC_STREAMOFF32: realcmd = cmd = VIDIOC_STREAMOFF; break;
    };

    switch(cmd) {
        ...
    case VIDIOC_STREAMOFF:
        err = get_user(karg.vx, (u32 __user *)up);
        compatible_arg = 1;
        break;
        ...
    };
    if(err)
        goto out;

    if(compatible_arg)
        err = native_ioctl(file, realcmd, (unsigned long)up);
    else {
        mm_segment_t old_fs = get_fs();

        set_fs(KERNEL_DS);
        err = native_ioctl(file, realcmd, (unsigned long) &karg);
        set_fs(old_fs);
    }
    ...
    return err;
}

long v4l_compat_ioctl32(struct file *file, unsigned int cmd, unsigned long arg)
{
    ...
        ret = do_video_ioctl(file, cmd, arg);
        break;
    ...
    return ret;
}

ざっと見た感じ、 ivtv ドライバからこの v4l_compat_ioctl32 ルーチンを呼んでも 特に問題は無いように思われる。

そこで、ivtv ドライバの file 構造体 (の中の file_operations 構造体) の compat_ioctl 関数ポインタに、 v4l_compat_ioctl32 を設定してみた。

--- linux-2.6.24.5.org/drivers/media/video/ivtv/ivtv-streams.c	2008-01-25 07:58:37.000000000 +0900
+++ linux-2.6.24.5/drivers/media/video/ivtv/ivtv-streams.c	2008-05-04 09:10:07.581416212 +0900
@@ -49,6 +49,7 @@
       .write = ivtv_v4l2_write,
       .open = ivtv_v4l2_open,
       .ioctl = ivtv_v4l2_ioctl,
+      .compat_ioctl = v4l_compat_ioctl32,
       .release = ivtv_v4l2_close,
       .poll = ivtv_v4l2_enc_poll,
 };
@@ -59,6 +60,7 @@
       .write = ivtv_v4l2_write,
       .open = ivtv_v4l2_open,
       .ioctl = ivtv_v4l2_ioctl,
+      .compat_ioctl = v4l_compat_ioctl32,
       .release = ivtv_v4l2_close,
       .poll = ivtv_v4l2_dec_poll,
 };

このパッチをあてることにより、 x86_64 なカーネル上で i386 な Video::ivtv モジュールを使って、 ビデオキャプチャ・カード GV-MVP/RX2W を コントロールすることができるようになった。 一週間ほど使ってみた (多数の TV 番組を予約録画した) が、 今のところ問題は起きていない。

2007年12月25日

2行でできる高精度ハードウェア自動認識 (initramfs の init を busybox だけで書く)

10行でできる高精度ハードウェア自動認識」にコメントを頂いた:

最近の modprobe は、 自分で勝手に modules.alias を探してくれるようになっているようです。 この機能を使うと、 より簡単かつ高速に自動認識が可能になります。

そうだったのか... orz

いままで、 modules.alias から modporbe すべきモジュールを検索するために、 以下のような感じで sh スクリプト (/tmp/dev2mod) を生成し、 それを読み込んで (. $tmp) いたのだが、

tmp=/tmp/dev2mod
echo 'dev2mod(){ while read dev; do case $dev in' > $tmp
sort -r /lib/modules/`uname -r`/modules.alias \
| sed -n 's/^alias  *\([^ ]*\)  *\(.*\)/\1)modprobe \2;;/p' >> $tmp
echo 'esac; done; }' >> $tmp
. $tmp
rm $tmp
cat /sys/bus/*/devices/*/modalias | dev2mod

modprobe が自分で modules.alias を探してくれるとなると、 sh スクリプトを動的生成する必要が無くなってしまい、 上記コードは次のように書けてしまう:

dev2mod(){ while read dev; do modprobe $dev; done }
cat /sys/bus/*/devices/*/modalias | dev2mod

わずかに 2行 (^^;)

/sys/bus/*/devices/*/modalias の内容を手当たり次第 modprobe するので、 modprobe が「failed to load module」というエラー・メッセージを出してしまうが、 特に問題は無さげである。

PCMCIA や USB につないだデバイスも、 以下のように dev2mod を二度呼び出すだけで、 自動認識してしまう。

dev2mod(){ while read dev; do modprobe $dev; done }
cat /sys/bus/*/devices/*/modalias | dev2mod
modprobe pcmcia
cat /sys/bus/*/devices/*/modalias | dev2mod

う~んすごい。

Filed under: プログラミングと開発環境 — hiroaki_sengoku @ 08:04
2007年11月22日

x86_64 Linux でメモリ・デバッグ・ツール Valgrind を使う場合の注意点

次のようなプログラム test.c について考える:

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <string.h>

struct test {
    int32_t len;
    int8_t buf[16];
};

int main(int argc, char *argv[]) {
    struct test *p = malloc(sizeof(struct test));
    int8_t buf[16];
    p->len = sizeof(p->buf);
    bzero(p->buf, p->len);
    printf("0x%lX-0x%lX => 0x%lX\n",
	   (long)p->buf, (long)p->buf+p->len-1, (long)buf);
    bcopy(p->buf, buf, p->len);
    free(p);
    return 0;
}

malloc(3) で確保した領域のうち、 16 byte を bcopy(3) でコピーするだけの極めて単純なプログラムであり、 特に問題はないように見える。

ところが memory debugging tool Valgrind を使って検証してみると、 x86_64 Linux だと次のようなエラーが出てしまう。

sag16:/home/sengoku/tmp % cc -O -Wall test.c
sag16:/home/sengoku/tmp % valgrind ./a.out
==19008== Memcheck, a memory error detector.
==19008== Copyright (C) 2002-2006, and GNU GPL'd, by Julian Seward et al.
==19008== Using LibVEX rev 1658, a library for dynamic binary translation.
==19008== Copyright (C) 2004-2006, and GNU GPL'd, by OpenWorks LLP.
==19008== Using valgrind-3.2.1-Debian, a dynamic binary instrumentation framework.
==19008== Copyright (C) 2000-2006, and GNU GPL'd, by Julian Seward et al.
==19008== For more details, rerun with: -v
==19008==
0x4D5C034-0x4D5C043 => 0x7FF000750
==19008== Invalid read of size 8
==19008==    at 0x4B9326B: (within /lib/libc-2.3.6.so)
==19008==    by 0x4B92C06: bcopy (in /lib/libc-2.3.6.so)
==19008==    by 0x4005BD: main (in /home/sengoku/tmp/a.out)
==19008==  Address 0x4D5C040 is 16 bytes inside a block of size 20 alloc'd
==19008==    at 0x4A1B858: malloc (vg_replace_malloc.c:149)
==19008==    by 0x400574: main (in /home/sengoku/tmp/a.out)
==19008==
==19008== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 8 from 1)
==19008== malloc/free: in use at exit: 0 bytes in 0 blocks.
==19008== malloc/free: 1 allocs, 1 frees, 20 bytes allocated.
==19008== For counts of detected errors, rerun with: -v
==19008== All heap blocks were freed -- no leaks are possible.

「Invalid read of size 8」、 すなわちアクセスすべきではないメモリを、 64bit (8 byte) 読み込み命令で読んだというエラー。

test.c で読み込みを行なう可能性があるところと言えば、 「bcopy(p->buf, buf, p->len);」の部分だけであり、 その範囲は printf で表示しているように、 0x4D5C034 番地から 0x4D5C043 番地までの 16 byte である。

ところが、Valgrind 曰く:

Address 0x4D5C040 is 16 bytes inside a block of size 20 alloc'd

ちょっと英語の意味が取りにくい (私の英語力が低いだけ? ^^;) が、 つまり「malloc で確保した 20 byte の領域のうち、 先頭から数えて 16 byte 目 (先頭は 0 byte 目と数える) が 0x4D5C040 番地であり、 この番地に対してメモリ読み込みが行なわれた」 という意味である (「16 byte 目」なら 「16 bytes」でなくて「16th byte」のような...?)。

すなわち、 「20 byte の領域のうち 16 byte 目」というのは残り 4 byte であり、 あと 4 byte コピーすればいいのにもかかわらず、 64bit 読み込み命令を使って 8 byte いっぺんに読んでしまっているから、 malloc で確保した領域の外をアクセスしてしまう、というわけ。

結果として 4 byte 無駄に読んでしまっている (実はコピー開始位置も 4 byte 前から行なうので、計 8 byte 余計に読み込んでいる) わけだが、 CPU にとって一番高速にコピーできる単位が (64bit 境界に合わせた) 64bit 読み書きだから、 bcopy の実装がこのようになっているのだろう。

より正確に言えば、 bcopy は 16 byte 以上のコピーを行なう場合は コピー開始位置手前の 64bit 境界 (alignment) の番地から 64bit ずつコピーし、 16 byte 未満の場合は byte 単位でコピーする。 test.c では、 コピー開始位置 p->buf が (直前のメンバが int32_t なので) 64bit 境界に一致しておらず、 しかもコピーする byte 数 p->len が 16 byte (= 64bit の倍数) なので、 16 byte 以上のコピーかつコピー終了位置も 64bit 境界に一致していない、 というのがミソである。

したがって 32bit な x86 Linux の場合であれば 32bit 単位でコピーを行なうので、 test.c ではこのようなエラーは起きない。 もちろん、64bit な x86_64 Linux で Valgrind がエラーを出すからといって、 bcopy の x86_64 における実装に問題がある、というわけではない。 Valgrind は、 あくまでバグの「可能性」を指摘するだけであって、 malloc で確保した領域の外へのアクセスでも、 それが意図的なものであれば (メモリ保護違反などでない限り) 何の問題もない。

分かってみれば単純な話なのであるが、 Valgrind のメッセージ「16 bytes inside a block」の意味が把握できなかった私は、 glibc の bcopy のソースを読んで 64bit 単位でコピーを行なっていることを知り、 4 byte の領域外読み込みが行なわれることを理解して初めて、 Valgrind のメッセージの意味が分かったという、 本末転倒な体験をした (^^;)。

ちなみに、 もちろん最初から上記のようなテストプログラムを Valgrind で チェックしようと思ったわけではなく、 「struct test」構造体は実際には次のような SockAddr 構造体であり、 saDup 関数にて malloc した SockAddr 構造体を doconnect 関数で bcopy する処理になっていて、 元ネタは拙作 stone である。

typedef struct {
    socklen_t len;
    struct sockaddr addr;
} SockAddr;
#define SockAddrBaseSize	((int)&((SockAddr*)NULL)->addr)
...

SockAddr *saDup(struct sockaddr *sa, socklen_t salen) {
    SockAddr *ret = malloc(SockAddrBaseSize + salen);
...

int doconnect(Pair *p1, struct sockaddr *sa, socklen_t salen) {
    struct sockaddr_storage ss;
    struct sockaddr *dst = (struct sockaddr*)&ss;	/* destination */
...
    bcopy(sa, dst, salen);
...

stone ML にて、 Valgrind で検証したらエラーが出た、という報告を頂いて (_O_) 以上のような調査を行なった次第。 bcopy に与えた引数に問題はなく、 どうしてこれが 「Invalid read of size 8」 エラーを引き起こすのか謎だった。 結果的には stone には問題はなく、 修正の必要もないことが判明したわけであるが、 今まで使っていなかった Valgrind を使ってみるいいきっかけになった。 実を言うと 64bit Linux を (プログラミングのレベルで) 使ったのも、 今回が初めてだったりする (^^;)。

Filed under: stone 開発日記,プログラミングと開発環境 — hiroaki_sengoku @ 20:36
2007年9月29日

10行でできる高精度ハードウェア自動認識 (initramfs の init を busybox だけで書く)

これまでLinuxのハードウェア自動認識と言えば、 /sys/bus/pci/devices 以下と、 /lib/modules/`uname -r`/modules.pcimap を照らし合わせて 解析していくのが定石でした。 USBにも対応しようとすると、もう一つ大変です。
しかしこれからの常識は、 /sys/bus/*/devices/*/modalias と
/lib/modules/`uname -r`/modules.alias です。
古橋貞之の日記「20行できる高精度ハードウェア自動認識」から引用

すばらしい。 確かに modules.alias を使う方が、 簡単かつ確実に必要なモジュールを読み込むことができそう。 さっそくこの方法を使って initramfs の init スクリプトを書き直してみた。

tmp=/tmp/dev2mod
echo 'dev2mod(){ while read dev; do case $dev in' > $tmp
sort -r /lib/modules/`uname -r`/modules.alias \
| sed -n 's/^alias  *\([^ ]*\)  *\(.*\)/\1)modprobe \2;;/p' >> $tmp
echo 'esac; done; }' >> $tmp
. $tmp
rm $tmp
cat /sys/bus/*/devices/*/modalias | dev2mod

わずかに 8行 (^^)
(9/30追記: modules.alias を逆順ソートしておく必要があることが判明、sort -r を追加)。

シェルスクリプト版はRuby版と比べて40倍くらい遅いので注意。
同ページ(古橋貞之の日記)から続けて引用

sh スクリプトの名誉のために言っておくと、 私が書いた上記 sh スクリプトだと、 古橋さんの Ruby 版と比べて 4倍くらいの遅さで済んでいる。

% time ./dev2mod
ide_cd
intel_agp
intelfb
uhci_hcd
...(中略)...
libusual
usbcore
0.252u 0.012s 0:00.77 33.7%	0+0k 0+0io 0pf+0w
% time ./detect_kmod.rb
["ivtv", "snd_intel8x0", "intelfb", "libusual", "ftdi_sio", "usbhid", "uhci_hcd", "ehci_hcd", "usbcore", "via_velocity", "eepro100", "e100", "3c59x", "psmouse", "ide_cd", "i2c_i801", "hw_random", "intel_agp"]
0.072u 0.008s 0:00.18 38.8%	0+0k 0+0io 0pf+0w

ちなみに古橋さんのスクリプトは、 modules.alias の各行それぞれに対し、 マッチするデバイスが /sys/bus/*/devices/*/modalias に存在すれば、 そのモジュールを読み込む処理になっている。
しかしながら、これだと一つのデバイスに対し、 複数のモジュールが読み込まれてしまうことになるのではないだろうか?

古橋さんが同日追記されているように、 複数のモジュールが読み込まれること自体は簡単に修正可能で、 むしろモジュールの読み込み順が modules.alias に載っている順になることのほうが問題。 この問題点を解決するため、 /sys/bus/*/devices/*/modalias の各行それぞれに対し、 マッチするモジュールを modules.alias から見つける修正版が追記された。 さすが古橋さん、すばやい。
9/30追記

例えば古橋さんのスクリプトだと、 私の手元のマシンでは e100 と eepro100 の両方のモジュールが読み込まれてしまう。 つまり、

% cat /sys/bus/pci/devices/0000:01:08.0/modalias
pci:v00008086d00001050sv0000107Bsd00004043bc02sc00i00

が、modules.alias の次の二つの行にマッチするため、 このようなことが起こる。

alias pci:v00008086d00001050sv*sd*bc*sc*i* eepro100
alias pci:v00008086d00001050sv*sd*bc02sc00i* e100

modules.alias を検索する際は、 マッチする行が見つかった時点で以降の行はスキップしないと、 この例のように複数のモジュール読み込みが起きる恐れがある。 マッチした以降の行を読み飛ばすには、 私が書いた上記 sh スクリプトのように、 /sys/bus/*/devices/*/modalias の各行それぞれに対し、 マッチするモジュールを一つだけ modules.alias から見つけて読み込む処理のほうが、 簡単に書けるのではないかと思うがどうだろうか。

とはいえ、実際の NIC は Intel Pro 10/100 だったりする (^^;) ので、 読み込むべきモジュールは e100 であるような気もする。 もし e100 が正しいモジュールであるのなら、 modules.alias における eepro100 のパターンが適切ではないということになるのかも。
9/30追記
「*」を多く含むパターンは「後で」マッチさせたほうが、 より適切なモジュールを選択できると考えられるため、 modules.alias を逆順ソートしておくことにした。 これにより、eepro100 ではなく、e100 を読み込むようになった。
9/30さらに追記

参考までに initramfs の /init スクリプト全体を添付しておく:

もっと読む...
2007年9月28日

chroot されたディレクトリから脱出してみる

要約すれば、 「chrootなんて簡単に抜けられるからセキュリティ目的で使っても意味ないよ。」 ってことね。そうだったのか。

そうだったのか orz

Note that this call does not change the current working directory, so that `.' can be outside the tree rooted at `/'. In particular, the super-user can escape from a `chroot jail' by doing `mkdir foo; chroot foo; cd ..'.
chroot(2) から引用

chroot するときは、そのディレクトリへ chdir しておくのが常識と 思っていたので気づいていなかった。 つまり、 故意にカレントディレクトリを chroot 外へもっていけば、 chroot されたディレクトリから抜け出せてしまう、ということ。

より正確に言えば、 chroot されたディレクトリの中で、 さらに chroot すれば、 その「親」chroot ディレクトリを抜け出せてしまう。 chroot がネストしないことを利用したテクニック、ということか。 逆に言えば、 chroot(2) 実行時にカレントディレクトリを chroot ディレクトリ下へ 強制的に移動させるか、 あるいは chroot がネストするようにすれば回避可能?

mkdir foo; chroot foo; cd ..

確かに本質はこの短いコードで言い尽くされているが、 こーいうのを見ると実地に試さずにはおれないので、コードを書いてみた。

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/stat.h>
#include <errno.h>
#define BUFMAX 256

int main(int argc, char *argv[]) {
    char buf[BUFMAX+1];
    sprintf(buf, "escape.%d", getpid());
    if (chdir("/") < 0) {
	printf("Can't chdir \"/\" errno=%d\n", errno);
	return 1;
    }
    if (mkdir(buf, 0755) < 0) {
	printf("Can't mkdir \"%s\" errno=%d\n", buf, errno);
	return 1;
    }
    if (chroot(buf) < 0) {
	printf("Can't chroot \"%s\" errno=%d\n", buf, errno);
	return 1;
    }
    if (rmdir(buf) < 0) {
	printf("Can't rmdir \"%s\" errno=%d\n", buf, errno);
	return 1;
    }
    if (!getcwd(buf, BUFMAX)) {
	printf("Can't getcwd errno=%d\n", errno);
	return 1;
    }
    printf("Now escaping from chrooted %s\n", buf);
    do {
	if (chdir("..") < 0) {
	    printf("Can't chdir \"..\" cwd=%s errno=%d\n", buf, errno);
	    return 1;
	}
	if (!getcwd(buf, BUFMAX)) {
	    printf("Can't getcwd errno=%d\n", errno);
	    return 1;
	}
    } while (buf[1] != '\0' && buf[0] == '/');
    if (chroot(".") < 0) {
	printf("Can't chroot \".\" errno=%d\n", errno);
	return 1;
    }
    argv++;
    execv(argv[0], argv);
    printf("Can't exec %s err=%d\n", argv[0], errno);
    return 0;
}

chdir / して、mkdir foo して、chroot foo して (rmdir foo して)、 その後に chdir .. でディレクトリ階層を上がれば抜け出せる。 言葉で書けば簡単だが、実際のコードを書こうとすると、 もう少し考えるべきことがあった。

すなわち、 抜け出した後、プログラムを終了してしまっては元の木阿弥であるので、 /bin/sh などを exec すべきであるし、 「本物」の / 下の /bin/sh をちゃんと実行するには、 「本物」の / へ chroot しなおす必要もある。 ここで注意すべきなのは、 「/」ディレクトリは元の chrooted なディレクトリのままという点だろう。 つまり chroot / してしまうと、 元の chrooted なディレクトリへ chroot してしまう (つまり何も変わらない)。

だから「/」を使わずに、 「chdir ..」で一段ずつディレクトリ階層を上っていって 「本物」の / にたどり着かねばならない。 上記コード中の while ループが「一段ずつ上っていく」処理である。 「本物」の / にたどりついたら chroot . する (くどいようだがここで chroot / してはいけない)。

試しに脱出してみる:

ikeda:/ # chroot /tmp/chroot /bin/sh
# ls -laR /
/:
drwxr-xr-x    3 0        0              29 Sep 28 17:05 .
drwxr-xr-x    3 0        0              29 Sep 28 17:05 ..
drwxr-xr-x    2 0        0              38 Sep 28 16:21 bin
-rwxr-xr-x    1 0        0         2111689 Sep 28 17:02 escape

/bin:
drwxr-xr-x    2 0        0              38 Sep 28 16:21 .
drwxr-xr-x    3 0        0              29 Sep 28 17:05 ..
-rwxr-xr-x    1 0        0         1392832 Sep  1 11:24 busybox
lrwxrwxrwx    1 0        0               7 Sep 28 16:21 ls -> busybox
lrwxrwxrwx    1 0        0               7 Sep 28 16:21 sh -> busybox
# ./escape /bin/sh
Now escaping from chrooted /tmp/chroot
sh-3.00# ls -la /tmp/chroot
total 2068
drwxr-xr-x 3 root root      29 Sep 28 17:05 .
drwxrwxrwt 8 root root    4096 Sep 28 17:05 ..
drwxr-xr-x 2 root root      38 Sep 28 16:21 bin
-rwxr-xr-x 1 root root 2111689 Sep 28 17:02 escape
sh-3.00#
もっと読む...
Filed under: プログラミングと開発環境 — hiroaki_sengoku @ 17:19
2007年9月27日

Linux でシンボリックリンクのタイムスタンプを変更する (utimensat システムコールを使って)

Linux 2.6.22 以前は、 シンボリックリンク (symbolic link) のタイムスタンプ (time stamp) を 変更することが出来なかった。 Linux (に限らず unix のほとんど全て) では、 シンボリックリンクも普通のファイルと同様、 アクセス日時と更新日時のタイムスタンプを保持している。 BSD な unix などでは、 lutimes システムコールを使ってシンボリックリンクのタイムスタンプを変更できる。

ところが、Linux には lutimes システムコールが存在しない。 したがってシンボリックリンクのタイムスタンプは、 そのシンボリックリンクを作成した時刻のままである。 tar などでアーカイブからリストアする場合や、 rsync などでディレクトリをコピーする場合などでも、 シンボリックリンクだけは元のタイムスタンプが復元されず、 復元した時刻のシンボリックリンクが作成されるので、 不便なことこのうえない。

Linux 2.6.22 になって、 utimensat システムコールが新設された。

Ulrich Drepper (glibc のメンテナ) は、 futimesat インターフェイスでは機能が足りていないということを理由に、 utimensat システムコールを提案しました。
futimesat は、inode のアクセス・変更時間を設定するためのシステムコールです。 struct timeval をパラメータとして受け取るため、 マイクロ秒単位で設定します。 一方、inode の各種情報を取得する stat というシステムコールでは、 ナノ秒単位で取得できるようになっています。 つまり、そもそも設定できない精度で情報を取得できるような仕組みに なっているわけです。
この問題を解決するために、 パラメータとして struct timespec (ナノ秒単位) を利用できる utimensat というシステムコールを用意することになりました。 このシステムコールは Linux カーネル 2.6.22-rc1 でマージされました。

この記事では、 ナノ秒単位で設定できることばかり強調しているが (私の感覚だとマイクロ秒がナノ秒になっても、あまり嬉しくない ;-)、 utimensat で機能拡張されたのはそれだけではない。

int utimensat(unsigned int dfd, char *filename, struct timespec *t, int flags);

引数が「struct timespec」になってナノ秒単位で設定できるようになったわけだが、 「utimensat」という名称から推測される通り、 他の *at (末尾に「at」がつく) システムコールと同様、 引数として与えたパス名 (char *filename) の扱いを指定できる。 /usr/include/fcntl.h には次のように書いてある。

#ifdef __USE_ATFILE
# define AT_FDCWD		-100	/* Special value used to indicate
					   the *at functions should use the
					   current working directory. */
# define AT_SYMLINK_NOFOLLOW	0x100	/* Do not follow symbolic links.  */
# define AT_REMOVEDIR		0x200	/* Remove directory instead of
					   unlinking file.  */
# define AT_SYMLINK_FOLLOW	0x400	/* Follow symbolic links.  */
# define AT_EACCESS		0x200	/* Test access permitted for
					   effective IDs, not real IDs.  */
#endif

utimensat の第四引数 int flags に「AT_SYMLINK_NOFOLLOW」を与えれば、 シンボリックリンクを「たどらず」に、 シンボリックリンクそのものに対して、 タイムスタンプの変更を行なうことができそうである (ちなみに futimesat は第三引数までしかなく int flags を指定できない)。

さっそく実験してみる:

int utimensat(int dfd, char *filename,
	      struct timespec *utimes, int flags) {
    register unsigned int ret;
    asm volatile (
	"movl %1, %%eax\n\t"
	"call *%%gs:%P2\n\t"
	: "=a" (ret)
	: "i" (320), "i" (16),
	  "b" (dfd), "c" (filename), "d" (utimes), "S" (flags)
	: "memory", "cc");
    return (long)ret;
}

手元の Linux マシンの glibc では、 utimensat を呼び出せるようにはなっていなかったので、 glibc のソースを参考にしながら utimensat システムコールを呼び出す関数を書いてみた。 コード中「320」などとハードコーディング (^^;) している数値は、 linux/include/asm-i386/unistd.h 中の、

#define __NR_utimensat		320

を意味している。 これで utimensat システムコールをユーザ空間から呼び出せるようになった。 もちろん、utimensat をサポートしている glibc であれば、 このような関数をデッチあげるまでもなく、 そのまま普通に utimensat を呼び出せばよい。

さっそく使ってみる:

#include <stdio.h>
#include <stdlib.h>
#define __USE_ATFILE
#include <fcntl.h>
#undef __USE_MISC
#include <sys/stat.h>

int main(int argc, char *argv[]) {
    int i;
    for (i=1; i < argc; i++) {
	struct stat lst, st;
	if (lstat(argv[i], &lst)) {
	    printf("Can't find: %s\nUsage: %s <file>...\n", argv[i], argv[0]);
	    exit(1);
	}
	if (S_ISLNK(lst.st_mode) && !stat(argv[i], &st)) {
	    struct timespec ts[2];
	    ts[0].tv_sec = st.st_atime;
	    ts[0].tv_nsec = st.st_atimensec;
	    ts[1].tv_sec = st.st_mtime;
	    ts[1].tv_nsec = st.st_mtimensec;
	    if (utimensat(AT_FDCWD, argv[i], ts, AT_SYMLINK_NOFOLLOW) < 0) {
		printf("Failed to utimensat %s %ld.%09ld\n",
		       argv[i], ts[1].tv_sec, ts[1].tv_nsec);
	    }
	}
    }
    return 0;
}

シンボリックリンクのタイムスタンプを、 シンボリックリンク先のファイル (or ディレクトリ、その他) のタイムスタンプと 一致させるプログラムである。 上記ソースプログラム (utimensat 関数と main 関数) を ltouch.c という名前で保存し、 「gcc -o ltouch ltouch.c」などとコンパイルした後、
「ltouch シンボリックリンクのパス名」などと実行する。

senri # ls -lt /usr/i486-linuxaout/lib/
total 20000
lrwxrwxrwx 1 root    root      16 Dec 22  2005 libPEX5.so.1 -> libPEX5.so.1.1.0*
lrwxrwxrwx 1 root    root      14 Dec 22  2005 libPEX5.so.6 -> libPEX5.so.6.0*
lrwxrwxrwx 1 root    root      15 Dec 22  2005 libX11.so.3 -> libX11.so.3.1.0*
lrwxrwxrwx 1 root    root      13 Dec 22  2005 libX11.so.6 -> libX11.so.6.0*
lrwxrwxrwx 1 root    root      13 Dec 22  2005 libXIE.so.6 -> libXIE.so.6.0*
lrwxrwxrwx 1 root    root      15 Dec 22  2005 libXaw.so.3 -> libXaw.so.3.1.0*
lrwxrwxrwx 1 root    root      13 Dec 22  2005 libXaw.so.6 -> libXaw.so.6.0*
lrwxrwxrwx 1 root    root      15 Dec 22  2005 libXpm.so.3 -> libXpm.so.3.3.0*
lrwxrwxrwx 1 root    root      13 Dec 22  2005 libXpm.so.4 -> libXpm.so.4.3*
lrwxrwxrwx 1 root    root      14 Dec 22  2005 libXt.so.3 -> libXt.so.3.1.0*
lrwxrwxrwx 1 root    root      12 Dec 22  2005 libXt.so.6 -> libXt.so.6.0*
lrwxrwxrwx 1 root    root      18 Dec 22  2005 libcurses.so.0 -> libcurses.so.0.1.2*
lrwxrwxrwx 1 root    root      15 Dec 22  2005 libdb.so.1 -> libdb.so.1.85.1*
lrwxrwxrwx 1 root    root      10 Dec 22  2005 libdbm.sa -> libgdbm.sa
lrwxrwxrwx 1 root    root      16 Dec 22  2005 libdosemu -> libdosemu-0.60.1
lrwxrwxrwx 1 root    root      14 Dec 22  2005 libe2fs.so.1 -> libe2fs.so.1.0*
lrwxrwxrwx 1 root    root      13 Dec 22  2005 libe2p.so.1 -> libe2p.so.1.0*
lrwxrwxrwx 1 root    root      12 Dec 22  2005 libet.so.1 -> libet.so.1.0*
lrwxrwxrwx 1 root    root      12 Dec 22  2005 libgr.so.1 -> libgr.so.1.3*
lrwxrwxrwx 1 root    root      12 Dec 22  2005 libss.so.1 -> libss.so.1.0*
lrwxrwxrwx 1 root    root      14 Dec 22  2005 libtcl.so.3 -> libtcl.so.3.1j*
lrwxrwxrwx 1 root    root      13 Dec 22  2005 libtk.so.3 -> libtk.so.3.1j*
lrwxrwxrwx 1 root    root      16 Dec 22  2005 libvga.so.1 -> libvga.so.1.0.11*
	...(後略)...

古いディレクトリだと、 このように同じタイムスタンプのシンボリックリンクばかり並んでしまって、 見にくいことこの上ない (おそらく 2005年12月22日に、ハードディスクを入れ替えたのだろう) のであるが、 「ltouch *」を実行すると、

senri # ltouch /usr/i486-linuxaout/lib/*
senri # ls -lt /usr/i486-linuxaout/lib/
total 20000
lrwxrwxrwx 1 root    root      14 Sep 11  1995 libPEX5.so.6 -> libPEX5.so.6.0*
-r-xr-xr-x 1 root    root  234500 Sep 11  1995 libPEX5.so.6.0*
lrwxrwxrwx 1 root    root      13 Sep 11  1995 libXIE.so.6 -> libXIE.so.6.0*
-r-xr-xr-x 1 root    root   58372 Sep 11  1995 libXIE.so.6.0*
lrwxrwxrwx 1 root    root      13 Sep 11  1995 libXaw.so.6 -> libXaw.so.6.0*
-r-xr-xr-x 1 root    root  209924 Sep 11  1995 libXaw.so.6.0*
lrwxrwxrwx 1 root    root      12 Sep 11  1995 libXt.so.6 -> libXt.so.6.0*
-r-xr-xr-x 1 root    root  320516 Sep 11  1995 libXt.so.6.0*
lrwxrwxrwx 1 root    root      13 Sep 11  1995 libX11.so.6 -> libX11.so.6.0*
-r-xr-xr-x 1 root    root  529412 Sep 11  1995 libX11.so.6.0*
lrwxrwxrwx 1 root    root      16 Jul 29  1995 libdosemu -> libdosemu-0.60.1
-rw-r--r-- 1 root    root  630973 Jul 29  1995 libdosemu-0.60.1
-rw-r--r-- 1 root    root   28912 Mar 19  1995 libdes.a
lrwxrwxrwx 1 root    root      14 Feb 28  1995 libe2fs.so.1 -> libe2fs.so.1.0*
-rwxr-xr-x 1 root    root   84035 Feb 28  1995 libe2fs.so.1.0*
lrwxrwxrwx 1 root    root      13 Feb 28  1995 libe2p.so.1 -> libe2p.so.1.0*
-rwxr-xr-x 1 root    root   51633 Feb 28  1995 libe2p.so.1.0*
lrwxrwxrwx 1 root    root      12 Feb 28  1995 libet.so.1 -> libet.so.1.0*
-rwxr-xr-x 1 root    root   56437 Feb 28  1995 libet.so.1.0*
lrwxrwxrwx 1 root    root      12 Feb 28  1995 libss.so.1 -> libss.so.1.0*
-rwxr-xr-x 1 root    root   63306 Feb 28  1995 libss.so.1.0*
lrwxrwxrwx 1 root    root      18 Feb 19  1995 libcurses.so.0 -> libcurses.so.0.1.2*
-rwxr-xr-x 1 root    root   49152 Feb 19  1995 libcurses.so.0.1.2*
	...(後略)...

このように、 シンボリックリンクとリンク先ファイルが同じタイムスタンプになるので、 「ls -lt」などと更新日時でソートすれば、 リンクとリンク先が隣り合わせになって見やすくなる。

Filed under: プログラミングと開発環境 — hiroaki_sengoku @ 09:22
2007年9月18日

NFS と AUFS (Another Unionfs) を使って、ディスクレス (diskless) サーバ群からなる低コスト・高可用な大規模負荷分散システムを構築する

ディスクレス (diskless) サーバを多数運用しようとしたときネックとなるのが、 NAS (Network Attached Storage) サーバの性能。 多数のディスクレスサーバを賄え、かつ高信頼な NAS サーバとなると、 どうしても高価なものになりがちであり、 NAS サーバ本体の価格もさることながら、 ディスクが壊れたときの交換体制などの保守運用費用も高くつく。

それでも、多数のハードディスク内蔵サーバ (つまり一般的なサーバ) を 運用して各サーバのディスクを日々交換し続ける (運用台数が多くなると、 毎週のようにどこかのディスクが壊れると言っても過言ではない) よりは、 ディスクを一ヶ所の NAS にまとめたほうがまだ安い、 というわけで NAS/SAN へのシフトは今後も進むだろう。 そもそも CPU やメモリなどとハードディスクとでは、 故障率のケタが違うのだから、 両者の台数を同じように増やせば破綻するのは当たり前。 要は、滅多に故障しないものは増やしてもいいが、 普通に故障するものは増やしてはいけない。 サーバの部品で故障する確率が桁違いに高いのはハードディスクだから、 大規模負荷分散環境においてディスクレス化は論理的必然だろう。

ハードディスクの故障率が高いのは可動部品が多いから、 というわけでハードディスクをフラッシュメモリで 置き換えようとする傾向もあるようだ。 確かに高価な NAS サーバを導入するよりは、 各サーバにフラッシュメモリを搭載する方が安上がりである可能性もある (比較的低容量であれば)。 しかしながら、 以下に述べるように NAS サーバを普通の PC サーバで実現できてしまえば、 ディスクレス化のほうが安いのは当たり前である。 サーバの台数が多くなればなるほど、 各サーバにディスク/フラッシュメモリを必要としない ディスクレス方式の方が有利になる。

とはいえ、 PC サーバの価格に慣れてしまうと、 超高価な専用サーバの世界にはもう戻れない。 そこで、 どうすればサーバ群のディスクレス化を、 低コストで行なうことができるか考えてみる。 そもそもなぜ NAS サーバが高価かと言えば、 高パフォーマンス性と高信頼性を兼ね備えようとするから。 多数のディスクレスサーバにストレージサービスを提供するのだから 高パフォーマンス性は譲れない。 となると犠牲にしてよいのは高信頼性ということになる。

信頼できなくてもよい、 つまり時々ディスクが壊れて、 書込んだはずのデータが失われても良いなら、 そこそこ高性能な PC サーバで用が足りてしまうだろう。 壊れた場合に備えて冗長化しておけば、 高信頼ではないものの、無停止性は達成できる。 もちろんマスターサーバが壊れてスレーブサーバに切り替われば、 マスターサーバにしか書込めなかったデータは失われる。

そんな NAS サーバは使えない、と言われてしまいそうであるが (^^;)、 ディスクに書込むデータで消えては困るデータとは何だろうか? 例えば Web サーバなどでは、 永続化が必要なデータは DB サーバへ書込むのが普通で、 それ以外のデータは消えては困るとは必ずしも言えないのではないか? というか消えては困るデータは DB サーバへ書けばいいのである。

さらに考えを一歩進めて、 ディスクに書込むデータは消えてもよい、 と割りきってしまうことができれば、 NAS サーバにデータを書く必要性すらなくなってしまう。 つまり NAS サーバのディスクを読み込み専用 (Read Only 以下 RO と略記) でマウントし、 書き込みはローカルな読み書き可能な (Read Write 以下 RW と略記) RAM ディスクに対して行なう。 NAS サーバは RO だから 内容が同じ NAS サーバを複数台用意して負荷分散させれば、 高パフォーマンスと故障時のフェールオーバを同時に達成できてしまう。 NAS サーバのクラスタリングが難しいのはデータを書込もうとするからであって、 書込む必要がなければ話は一気に単純になる。

もちろん全く何も書込めない NAS サーバというのはナンセンスだろう。 ここで「書く必要がない」と言っているのは、 アプリケーション実行中にアプリケーションの動作に同期して (つまり動作結果を) 「書く」必要性である。 アプリケーションとは非同期な書込み、 例えば何らかのコンテンツを配信する Web サーバを考えたとき、 あらかじめ大量の「コンテンツ」を NAS サーバへ事前に保存しておく場合や、 あるいは「コンテンツ」を定期的に更新する場合は、 (アプリケーションの動作結果とは無関係な書き込みなので) Web アプリケーションが NAS へ書込む必要はない。
むしろ、 大量の「コンテンツ」を NAS サーバに集中することは、 コンテンツ更新が素早く行なえるというメリットとなる。 多数の Web サーバそれぞれにハードディスクを内蔵して 同じコンテンツをコピーしていては、 コンテンツの更新頻度が上がってくると 全 Web サーバの内容を同期させるのが難しくなってくるからだ。

ここで重要なのは、 上記「RO NAS サーバ + RW ローカル RAM ディスク」が、 ディスクレスサーバ上で動くソフトウェア (例えば Web アプリケーションやミドルウェア) から見ると、 普通の RW ローカルディスク (つまり普通に書込み可能なハードディスク) に見えなければならないという点である。 もしソフトウェア側で特別な対応が必要だと、 ソフトウェアの改修コストがかかってしまう。 ハードウェアのコストを下げようとして ソフトウェアのコストが上がってしまっては本末転倒である。 ディスク上のデータが RO な NAS サーバから読み込まれたものであり、 ディスクへ書込んだデータが、実は RAM ディスクに書込んだだけで、 再起動によって消えてしまうものであったとしても、 ソフトウェアから見れば、 普通の RW ハードディスクディスクのように 振る舞わなければならないのである。

このように、 複数のディスク (RO NAS と RW RAM ディスク) を重ねて 一つのディスクとして見せる仕掛けを、 重ね合わせ可能な統合ファイルシステム (Stackable Unification File System) と呼ぶ。 Linux 2.6.20 以降の場合、 二種類の統合ファイルシステムが利用可能である。 後発の Aufs (Another Unionfs) を利用して、 ディスクレスサーバを作ってみた。
(あいかわらず) 前フリが長いが (^^;)、ここからが本題である。

もっと読む...
Filed under: システム構築・運用,プログラミングと開発環境 — hiroaki_sengoku @ 06:53
2007年9月4日

initramfs (initrd) の init を busybox だけで書いてみた

linux をブートさせる際、 さまざまな PC に対応させようとすると、 多くのデバイスドライバをカーネルに組み込んでおかねばならない。 つまりルートファイルシステム (root file system, 以下 rootfs と略記) をマウントするまでは、 その rootfs 上にインストールしたモジュール群 (/lib/modules/ の下に置いた *.ko ファイル群) を読めないからだ。 rootfs さえマウントできてしまえば、 あとはいくらでもモジュールを必要に応じて読み込むことができるようになる。 だから、さまざまなハードウェアへの対応といっても、 重要なのは rootfs をマウントするまで、である。

しかしながら、 rootfs をマウントするまでの辛抱といっても、 rootfs をマウントするにはハードディスクを認識しなければならないし、 それには ATA ドライバやら SCSI ドライバやら、 果ては AHCI ドライバなどが、 ハードウェアに応じて必要になる。

個人で管理する PC の全てのハードウェアに対応させるだけなら、 全てのドライバをあらかじめカーネルに読み込んでおくのもアリだろう。 しかし汎用的なディストリビューションなど、 (カーネル再構築を行なわずに) 多くのハードウェアに対応する必要がある場合は、 必要になるかも知れない全てのドライバを、 あらかじめカーネルに読み込んでおくなどということは非現実的である。

ちなみに私は、1993年頃から linux を使っているが、 いまだに当時インストールした slackware を使用し続けている。 もちろん kernel や libc をはじめとして、 ほぼ全てのソフトウェアをアップデートしてしまっているし、 しかも起動スクリプトをはじめとして、 あらゆる設定を好き勝手に書き換えてしまっているので、 インストールしてから 10年以上たった今となっては、 元の slackware の痕跡は全くといっていいほど残っていない。 もはや、私独自のディストリビューションと呼んでしまっても差し支えないだろう。 私が個人的に管理しているマシンには、 全てこの「my distribution」をインストールしている。 そんなわけで、私は「普通の」ディストリビューションを使ったことがない。 initrd がディストリビューションの「常識」となってからも、 私は initrd は使わずに、 自分が管理する PC のハードウェアに合わせてカーネルを再構築して使ってきた。

そこで linux では、 initrd (Initial RAM Disk) という仕掛けが使われてきた。 すなわちハードディスクを rootfs としてマウントする前に、 一時的にマウントする「ミニ」ルート (mini root) である。 このミニルートには、 ハードディスク (あるいは 1CD Linux の場合であれば CD だし、 ネットワークブートする場合であれば NFS サーバ) を rootfs としてマウントするのに必要となる可能性がある モジュール群一式を置いておき、 ハードウェアに応じて必要なモジュールをミニルートから読む。 そして、ハードディスクをマウントして、 / (ルート) をミニルートからハードディスクへ切り替える。

ただ、この initrd は少々扱いが面倒くさい。 initrd は RAM ディスクという「本物の」ブロックデバイスなので、 「本物の」ファイルシステム (例えば ext2) で mkfs しなければならない。 initrd にモジュールを追加しようとすれば、 initrd イメージを (losetup コマンドを使って) ループバックデバイス経由でマウントして内容を書き換えなければならないし、 たくさんのモジュールを追加した結果、 もしファイルシステムが一杯にでもなったりしたら、 initrd イメージのサイズを大きくして mkfs からやり直しである。

メンドクサイだけでなく、 RAM ディスク自体が非効率なものであるようで、 ファイルからブロックデバイスを作る方法としては、 すでに「semi-obsolete」とまで言われてしまっているようである:

Another reason ramdisks are semi-obsolete is that the introduction of loopback devices offered a more flexible and convenient way to create synthetic block devices, now from files instead of from chunks of memory. See losetup (8) for details.
linux/Documentation/filesystems/ramfs-rootfs-initramfs.txt から引用

というわけで、initrd に代わる仕掛けとして、 linux kernel 2.6 からは initramfs と呼ばれる仕掛けが導入された。 すなわち RAM ディスクというブロックデバイスを用いるのではなく、 RAM 上に直接ファイルシステムを作る ramfs を用いた「ミニルート」である。 私自身は今まで initrd を使っていなかったのであるが、 cpio アーカイブを作るだけでいいというのは、 とても手軽であるように思えたし、 カーネルにどんどんドライバを組み込んで肥大化させるよりは、 initramfs を使う方がヨサゲである (もちろん、どんどんドライバを組み込めば、 initramfs が肥大化するのだが、 カーネルが肥大化するデメリットとは比較にならない) ように感じてきたので、 宗旨替えすることにした。

initrd initramfs
イメージ ファイルシステム (ext2など + gzip) アーカイブ (cpio + gzip)
実装 ブロックデバイス (RAM ディスク) ファイルシステム
実行 /linuxrc /init
rootfs
マウント
適当なディレクトリへマウントして
pivot_root
/ へマウント (switch_root)
init 起動 /linuxrc 終了後、カーネルが起動 /init が exec /sbin/init する

ブートパラメータとして「initrd=」を与えると、 ブートローダがイメージをメモリ上に読み込んでカーネルに渡す。 するとカーネルはそのイメージがファイルシステムなのか、 cpio アーカイブなのか調べる。 もしファイルの magic number が cpio であれば、 ramfs としてマウントする。 そして /init が実行可能ならば、 initramfs として扱い、 /init を起動する。

以上の条件が一つでも成立しない場合、 すなわち cpio アーカイブでない場合や、 /init が実行できない (/init が存在しない) 場合は、 initrd 扱いになるので注意が必要である。 すなわち RAM ディスクとしてマウントしようとするので、 カーネルに RAM ディスクドライバが組み込まれていなかったり、
「root=/dev/ram0」カーネルパラメータを指定していなかったりすると、 kernel panic を起こす。

実は、initramfs として起動できるようになるまで、 かなりハマってしまった。 まず、cpio アーカイブを作るところで、いきなりハマった。

(cd /usr/src/initramfs/; find . | cpio -o -H newc ) | gzip > initrd.gz

などとしてアーカイブを作ればいいだけの話なのであるが、 このコマンドラインを /bin/csh 上で実行したために、 アーカイブの先頭にゴミが入ってしまった。 つまり、

senri:/ % (cd /usr/src/initramfs/; find . | cpio -o -H newc ) | cpio -tv | head
cpio: Malformed number 0000000.
cpio: Malformed number 000000.
cpio: Malformed number 00000.
cpio: Malformed number 0000.
cpio: Malformed number 000.
cpio: Malformed number 00.
cpio: Malformed number 0.
cpio: Malformed number .
cpio: warning: skipped 56 bytes of junk
drwxr-xr-x  13 root     root            0 Aug 27 17:56 .
drwxr-sr-x   2 root     root            0 Aug 25 10:00 bin
-rwxr-xr-x   1 root     root      1392832 Aug 25 18:36 bin/busybox
lrwxrwxrwx   1 root     root            7 Aug 25 10:09 bin/addgroup -> busybox
lrwxrwxrwx   1 root     root            7 Aug 25 10:09 bin/adduser -> busybox
lrwxrwxrwx   1 root     root            7 Aug 25 10:09 bin/ash -> busybox
lrwxrwxrwx   1 root     root            7 Aug 25 10:09 bin/cat -> busybox
lrwxrwxrwx   1 root     root            7 Aug 25 10:09 bin/catv -> busybox
lrwxrwxrwx   1 root     root            7 Aug 25 10:09 bin/chattr -> busybox
lrwxrwxrwx   1 root     root            7 Aug 25 10:09 bin/chgrp -> busybox

何が起きているかお分かりだろうか? お恥ずかしながら、アーカイブにゴミが混入していると気づくまで、 何度も kernel を panic させてしまった。 シェルのカスタマイズをやりすぎるとロクなことにならない、 という典型例なのかも (^^;)。

senri:/ % (cd /usr/src/initramfs/; echo test) | od -t a
0000000 esc   E   m   A   c   S   c   d  sp   /   u   s   r   /   s   r
0000020   c   /   i   n   i   t   r   a   m   f   s  nl esc   E   m   A
0000040   c   S   c   d  sp   /   u   s   r   /   s   r   c   /   i   n
0000060   i   t   r   a   m   f   s  nl   t   e   s   t  nl
0000075
senri:/ % alias cd
set back="$cwd";chdir !*;if(!* =~ "..")set cwd="$back:h";chdir "$cwd";setProm
senri:/ % alias setProm
set prompt="${HOST}:${cwd} $prompt_tail_char "

この alias 設定は、 もうかれこれ 10年以上使い続けてきた設定。 こんな形で悪さをするとは... orz

ようやくマトモなアーカイブを作れたと思ったら、 今度は以下のような Kernel panic が起きた:

Unpacking initramfs... done
Freeing initrd memory: 1412k freed
...(中略)...
No filesystem could mount root, tried:
Kernel panic - not syncing: VFS: Unable to mount root fs on unknown-block(8,1)

「Unpacking initramfs... done」と出ているのだから、 cpio アーカイブはちゃんと展開できて ramfs としてマウントできているはず。 なぜに「Unable to mount root fs」なのか、 と思っていたのだが、 これは initramfs に「/init」が無いためだった (エラーメッセージが不親切杉!)。 initrd みたいなものだろうと思って、 起動スクリプトを「/linuxrc」というファイル名で作っていたのが敗因。

「/init」がないと initrd 扱いになってしまい、 RAM ディスクを mount しようとしていたが、 RAM ディスクドライバが組み込まれていなかったのでマウントできない、 というのが、このエラーメッセージの主旨だったようだ (素直に /init が見つからない、って言ってくれればいいのに...)。 「/linuxrc」を「/init」へファイル名変更してみると、 あっさり initramfs 上での起動に成功した。

% ls -lt /boot/*-2.6.20.*
-rw-r--r-- 1 root root 1643881 Sep  3 07:55 /boot/initz-2.6.20.18
-rw-r--r-- 1 root root 1193392 Sep  1 23:10 /boot/linuz-2.6.20.18
-rw-r--r-- 1 root root 2017936 May 12 19:31 /boot/linuz-2.6.20.11

2.6.20.11 を make したときは、 rootfs のマウントに必要なドライバを全てカーネルに詰め込んでいたのに対し、 2.6.20.18 では、 マウントに必要なドライバは極力 initramfs に入れた。 これにより linuz 単体のサイズは半分近くに減っている。 linuz (カーネル) + initz (initramfs) の合計サイズは 2.6.20.11 に比べ増えてしまっているが、 これは busybox だけで 1.3MB ほどあるため。 とはいえ、 initramfs 内では busybox よりも lib/modules 以下のサイズの方が倍ほど大きいので、 非効率というほどでもない。

senri:/usr/src/initramfs % ls -l bin/busybox
-rwxr-xr-x 1 root root 1392832 Sep  1 11:24 bin/busybox
senri:/usr/src/initramfs % du --max-depth=2 --byte
1397502	./bin
4740	./sbin
6204	./usr/bin
4334	./usr/sbin
8226	./usr/share
22860	./usr
4096	./dev/pts
4096	./dev/shm
12338	./dev
4108	./etc
4096	./mnt
2422679	./lib/modules
2426775	./lib
4096	./proc
4096	./tmp
4096	./var
4096	./sys
3895272	.

じゃ、いよいよハードウェアの自動認識をして、 適切なモジュールのみを組み込むようにしてみようかと思って、 いろいろ探してみたのだが、 どうしたことか適当なスクリプトが見当たらない。 1CD Linux の /linuxrc をいろいろ読んでみたのだが、 いまいちパッとするものがない。 デバイスの ID などがゴリゴリ書いてあるものが大半で、 どれもアドホックすぎるように思えたのである。

かといって、ハードウェアの認識を udev などに行なわせる、 というのは牛刀過ぎるように思えた。 なんたって init を起動する前のブートストラップなのである。 目的は rootfs をマウントするだけなのであるから、 あまりに汎用的な仕掛けは、いかがなものかと思うのである。

というわけで、 busybox だけでハードウェアの自動認識 & モジュール読み込みを実現することを 目標にしてみた。 前フリが長くなった (長すぎ!) が、ようやくここからが本題である。

もっと読む...
2007年8月16日

CPAN の IP::Country を C で書き直して MTA に組み込み、メールのヘッダに国コードを挿入するようにしてみた

自前のブラックリストを用いて迷惑メール (spam, UBE) を排除する方法について、 「迷惑メール送信者とのイタチごっこを終わらせるために (1)」で説明した。 メールの送信元 IP アドレスが DNS で逆引きできない場合に、 その IP アドレスがブラックリストに載っているか否かを調べ、 もし載っているならその IP アドレスを 「ダイアルアップ IP アドレス」に準じる扱いにする、 という方法である。

迷惑メールを送ってきた実績 (?) がある IP アドレスブロックであれば、 ためらうこと無くブラックリストに入れてしまえるのであるが、 初めてメールを送ってきた IP アドレスブロックを、 逆引きできないという理由だけでダイアルアップ IP アドレス扱いするのは、 少々乱暴だろう。 そこで、 接続元 IP アドレスが属する国のコードをメールヘッダに挿入する仕掛けを MTA (Message Transfer Agent, メールサーバ) に作り込んでみた。例えば、

Received: from unknown (HELO unknown.interbgc.com) (89.215.246.95)
  by senri.gcd.org with SMTP; 15 Aug 2007 20:46:15 +0000
X-Country: BG 89.215.246.95
Received-SPF: pass (senri.gcd.org: SPF record at thelobstershoppe.com designates 89.215.246.95 as permitted sender)
Message-ID: <34f301c7df7d$2e65ece5$5ff6d759@unknown.interbgc.com>

といった感じで、「X-Country: 」フィールドが挿入される。 「89.215.246.95」がこのメールを送ってきたマシンの IP アドレスであり、 その前の「BG」が、 この IP アドレスが属する国 (この例ではブルガリア) の ISO 3166 コード である。

ブルガリアに知り合いがおらず、 かつこのメールがメーリングリスト宛でなく個人アドレス宛であるならば、 MUA (Message User Agent, メーラー) の設定で、 このメールを迷惑メールとして排除することが可能だろう。 あるいは逆に、 「X-Country: JP」である場合は、 迷惑メール判定の結果にかかわらず排除しないという設定にして、 必要なメールを誤って排除するのを防止することもできるだろう (日本語の迷惑メールも、大半は海外の IP アドレスから送信されている)。

IP アドレスから国コード (ISO 3166 コード) を調べるサービスはいろいろあるが、 メールを受信するたびに外部のサイトへ通信するのはあまり感心しない。 ネットワークないし外部のサイトの状況の影響を受けてしまうし、 あるいは逆に大量のメールを一時に受信したときなど、 そのサイトに迷惑をかけてしまう恐れもある。 集中して問合わせを行なってしまった、などの理由で濫用と判断され、 サービスの提供が受けられなくなってしまう可能性もある。

したがって、IP アドレスから国コードを検索するためのデータベースを 自前で持つことが望ましい。 例えば CPAN には、 IP アドレスから国コードを検索するモジュール 「IP::Countryが 登録されている。 このモジュールをインストールすると、 「/usr/lib/perl5/site_perl/5.8.8/IP/Country/Fast」ディレクトリに、 「ip.gif」と「cc.gif」というファイルがインストールされる。

% ls -l /usr/lib/perl5/site_perl/5.8.8/IP/Country/Fast
total 256
-r--r--r-- 1 perl perl    681 Feb  2  2007 cc.gif
-r--r--r-- 1 perl perl 252766 Feb  2  2007 ip.gif

「ip.gif」が、IP アドレスから国番号を検索するためのデータベースであり、 「cc.gif」が、国番号から国コード (ISO 3166 コード) への変換テーブルである。

メールを受信するたびにメールサーバで perl スクリプトを実行するのは、 メールサーバの負荷などの観点からあまり望ましくない (私のサイトではメールサーバを chroot 環境で動かしていて、 その chroot 環境には perl をインストールしていない、 というセキュリティ上の理由もある) ので、 ほとんど perl スクリプトをそのまま C に置き換えただけなので、 説明は不要だろう。 inet_ntocc 関数に限ると、 C 版のほうが perl 版より簡潔に書けてしまっている点が興味深い。 コメントは、IP/Country/Fast.pm スクリプトのコメントをそのまま入れてある。

#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <string.h>

#ifndef DBDIR
#define DBDIR "/usr/lib/perl5/site_perl/5.8.8/IP/Country/Fast"
#endif
#define CC_MAX	256	/* # of countries */

int inet_ntocc(u_char *ip_db, u_long inet_n) {
/*
  FORMATTING OF EACH NODE IN $ip_db
  bit0 - true if this is a country code, false if this
         is a jump to the next node
  
  country codes:
    bit1 - true if the country code is stored in bits 2-7
           of this byte, false if the country code is
           stored in bits 0-7 of the next byte
    bits 2-7 or bits 0-7 of next byte contain country code
  
  jumps:
    bytes 0-3 jump distance (only first byte used if
           distance < 64)
*/
    u_long mask = (1 << 31);
    const u_long bit0 = 0x80;
    const u_long bit1 = 0x40;
    int pos = 4;
    u_char byte_zero = ip_db[pos];
    /* loop through bits of IP address */
    while (mask) {
	if (inet_n & mask) {
	    /* bit[$i] is set [binary one]
	       - jump to next node
	       (start of child[1] node) */
	    if (byte_zero & bit1) {
		pos = pos + 1 + (byte_zero ^ bit1);
	    } else {
		pos = pos + 3 + ((ip_db[pos] << 8 | ip_db[pos+1]) << 8
				 | ip_db[pos+2]);
	    }
	} else {
	    if (byte_zero & bit1) {
		pos = pos + 1;
	    } else {
		pos = pos + 3;
	    }
	}
	/*
	  all terminal nodes of the tree start with zeroth bit 
	  set to zero. the first bit can then be used to indicate
	  whether we're using the first or second byte to store the
	  country code */
	byte_zero = ip_db[pos];
	if (byte_zero & bit0) {
	    if (byte_zero & bit1) {
		/* unpopular country code - stored in second byte */
		return ip_db[pos+1];
	    } else {
		/* popular country code - stored in bits 2-7
		   (we already know that bit 1 is not set, so
		   just need to unset bit 1) */
		return byte_zero ^ bit0;
	    }
	}
	mask = (mask >> 1);
    }
    return -1;
}

u_char *getdb(char *file, int *fdp, int *lenp) {
    char path[PATH_MAX+1];
    int fd;
    struct stat st;
    int length;
    u_char *db;
    snprintf(path, PATH_MAX, "%s/%s", DBDIR, file);
    path[PATH_MAX] = '\0';
    fd = open(path, O_RDONLY);
    if (fd < 0) {
	fprintf(stderr, "Can't open: %s err=%d\n", path, errno);
	exit(1);
    }
    if (fdp) *fdp = fd;
    if (fstat(fd, &st) < 0) {
	fprintf(stderr, "Can't stat: %s fd=%d err=%d\n", path, fd, errno);
	exit(1);
    }
    length = st.st_size;
    if (lenp) *lenp = length;
    db = (u_char*)mmap((void*)0, length, PROT_READ, MAP_SHARED, fd, 0);
    if (db == MAP_FAILED) {
	fprintf(stderr, "Can't map: %s fd=%d len=%d err=%d\n",
		path, fd, length, errno);
	exit(1);
    }
    return db;
}

int main(int argc, char *argv[]) {
    int i;
    u_char *ip_db = getdb("ip.gif", NULL, NULL);
    const char *_cc = 
	"USDEGBNL--FREUBEITESCACHRUSEAUAT"
	"PLCZIEFIJPDKNOUAZANGILROGRCNPTHU"
	"INTRIQSGHKCYIRLTNZKRLUBGAEARBRSI"
	"IDCLSKTWSAMYTHYUMXLVCOPHLBPKKZGH"
	"EETZKWKERSBDHRDZEGVECMPEECLIPRGE"
	"ISMEAPBYMGMDAOCIMTSOPAZWVNBANEBH"
	"PSJOCDMZAZMAUZDOBJAMUGSLCGGNZMMU"
	"TJMCANMWJMGIMKCRBMLRBOUYGTBWLKGP"
	"ALGAMQKGTTNASVLYMNMRAFNPSNKHBBQA"
	"CUGLBNOMMOPYSYPGNISMMLSCDJSZLSBF"
	"CFGUNCVGHNFJPFTDLAYEFOBISDGQRWKY"
	"**BSSRADGMCVGDKNETERRETNTMTGYTMV"
	"VIHTKMSTGWAGVABZBTNRTOFKKIVUMPWS"
	"MMAWSBJEGFAIAQIOGYNFLCPWCKDMAXFM"
	"TVNUAS--------------------------"
	"--------------------------------";
#ifdef CHECKCC
    int cc_fd;
    int cc_len;
    int cc_num;
    u_char *cc_db = getdb("cc.gif", &cc_fd, &cc_len);
    char cc[CC_MAX * 2 + 1];
    cc_num = cc_len / 3;
    if (cc_num < 0 || CC_MAX <= cc_num) {
	fprintf(stderr, "Can't happen: irregular CC DB cc_num=%d\n", cc_num);
	exit(1);
    }
    for (i=0; i < CC_MAX; i++) {
	cc[i*2] = '-';
	cc[i*2+1] = '-';
    }
    cc[i*2] = '\0';
    for (i=0; i < cc_num; i++) {
	u_char c = cc_db[i*3];
	cc[c*2] = cc_db[i*3+1];
	cc[c*2+1] = cc_db[i*3+2];
    }
    munmap(cc_db, cc_len);
    close(cc_fd);
    if (strcmp(cc, _cc) != 0) {
	for (i=0; i < CC_MAX; i+=16) {
	    int j;
	    printf("\"");
	    for (j=0; j < 16; j++) {
		printf("%c%c", cc[(i+j)*2], cc[(i+j)*2+1]);
	    }
	    printf("\"\n");
	}
    }
#else
    const char *cc = _cc;
#endif
    for (i=1; i < argc; i++) {
	u_long in = ntohl(inet_addr(argv[i]));
	int c = inet_ntocc(ip_db, in);
	if (c < 0) {
	    printf("UNKNOWN %s\n", argv[i]);
	} else {
	    printf("%c%c %s\n", cc[c*2], cc[c*2+1], argv[i]);
	}
    }
    return 0;
}

国コードへの変換テーブル「cc.gif」は、 変更頻度もさほど高くないだろうと思われたので、 プログラム中に固定文字列として定義している。 コンパイル時に「-DCHECKCC」を指定することにより、 cc.gif と内蔵の変換テーブルが一致するかチェックできる。

あとは、このコードを MTA に組み込むだけ。 私のサイトでは qmail を 使っているので、 qmail-smtpd.c にこのコードを組み込んだ。

Filed under: システム構築・運用,プログラミングと開発環境 — hiroaki_sengoku @ 07:42
2007年3月19日

オープンソース版 VPN-Warp リレー サーバ (Perl POE を使って実装)

Perl の非同期I/Oモジュール POE を使って VPN-Warp relayagent を書いてみました」に 続いて、 同じく POE を使って VPN-Warp リレー サーバも書いてみました。 これで、オープンソースだけを使って VPN-Warp を実現することができます。

今までも、 BIGLOBE の VPN ワープのページから証明書を取得すれば、 月額 525円で VPN-Warp を試してみることはできたわけですが、 ちょっと試してみたい場合など、 有料であることがネックである感は否めませんでした。 特に、 常日頃からオープンソースを使いこなしている方々だと、 ちょっと使ってみたいだけなのにお金を払うのはねぇ、 と思ってしまうのではないでしょうか。 かくいう私も、 無料「お試し版」のサービスやソフトウェアに慣れきってしまっているので、 試しに使ってみようとする場合に、 それが有料だったりすると、 いきなり億劫になってしまう今日このごろだったりします (^^;)。

というわけで、 オープンソース版 VPN-Warp です。 使い方はあまりフレンドリーではありませんが、 なんたって全て公開してしまっているので、 興味あるかたは、 とことんいじってみてはいかがでしょうか。

relayagent.pl と同様、 今回公開する relayserver.pl も SSL 暗号化/復号の機能を含んでいません。 したがってリレー サーバへの https アクセスを stone などを 通して SSL 復号する必要があります。 例えば stone を

stone -z cert=cert.pem -z key=priv.pem \
      localhost:12345 443/ssl &

などと実行しておき、 relayserver.pl を

relayserver.pl 12345

と実行します。これだけで 443番ポートはリレー サーバとして利用できます。 つまり、relayagent とブラウザからの https 接続を受付けると、 リレー サーバが両セッションを中継し 「ブラウザ → リレー サーバ → relayagent → Webサーバ」 という経路で通信できます。

                      リレー            イントラ         イントラ
ブラウザ ─────→ サーバ ←──── relayagent──→ Webサーバ
            https     443番ポート                        80番ポート

見かけは極めて tiny ですが、 通信プロトコルは本物(?)の VPN-Warp と互換性があるので、 「VPN-Warp relayagent フリー ダウンロード」から ダウンロードできる VPN-Warp relayagent を使うこともできます。

そもそも論で言えば、 リレー サーバの役目は単にデータを右から左へ渡すだけなので、 以下に示すようにその中核の部分は極めてシンプルです。 しかしながら、もちろんこれは KLab(株) で運用しているリレー サーバが単純であることを意味しません。 機能がシンプルでも、大量の同時接続 & 大量データを受付ける耐高負荷性能や、 機器の一部に故障が起きてもサービスが影響を受けない高可用性を実現するために、 様々な工夫を盛り込んでいます。

では、relayserver.pl の中身を順に見ていきましょう。

#!/usr/bin/perl
use POE qw(Component::Server::TCP Filter::Stream);
my $Port = shift;
my $PollID;
my $PollHeap;
my $PollBuf;
my $PollHeader;
my %SID;
my %Heap;
my %Buf;
my $NextSID = 0;

POE::Component::Server::TCP->new
    (
     Port => $Port,
     ClientInput => sub {
	 my ($heap, $input, $id) = @_[HEAP, ARG0, ARG1];
	 if (defined $PollID && $id == $PollID) {
	     $PollHeap = $heap;
	     $PollBuf .= $input;
	     &doPoll;
	 } elsif (defined $SID{$id}) {
	     my $sid = $SID{$id};
	     $Heap{$sid} = $heap;
	     $Buf{$sid} .= $input;
	     &doSession($sid);
	 } elsif ($input =~ m@^GET /KLAB/poll @) {
	     if (defined $PollID) {
		 $heap->{client}->
		     put("HTTP/1.1 503 Service Unavailable\r\n\r\n");
		 $heap->{client}->shutdown_output;
		 return;
	     }
	     $PollID = $id;
	     $PollHeap = $heap;
	     $PollBuf = $input;
	     &doPoll;
	 } else {
	     $SID{$id} = $NextSID;
	     $NextSID = ($NextSID + 1) & 0xFFFF;
	     my $sid = $SID{$id};
	     $Heap{$sid} = $heap;
	     $Buf{$sid} = $input;
	     &doSession($sid);
	 }
     },
     ClientDisconnected => sub {
	 my $heap = $_[HEAP];
	 my $id = $heap->{client}->ID;
	 if (defined $PollID && $id == $PollID) {
	     undef $PollHeap;
	     undef $PollBuf;
	     undef $PollHeader;
	     undef $PollID;
	 } elsif (defined $SID{$id}) {
	     my $sid = $SID{$id};
	     undef $SID{$id};
	     undef $Heap{$sid};
	     undef $Buf{$sid};
	 }
     },
     ClientFilter => POE::Filter::Stream->new(),
    );
POE::Kernel->run;

わずか 70行にも満たないコードですが、 リレー サーバの中核の部分は、ほとんどこれで全てです。 いかに POE (Perl Object Environment) の 記述性が高いか分かりますね。

私は常日頃から プログラマの生産性は、ピンとキリでは 3桁の違いがある と主張しています。 この主張をもう少し詳しく言うと、 その 3桁のうち、プログラマの腕に純粋に依存する部分は 2桁ほどの違いで、 残り 1桁ぶんは解決すべき問題に応じていかに最適な道具を使うかの違い、 ということになります。 最適な道具を使いこなせるもの腕のうち、 ということもできますね。

上記 70行にも満たないコードですが、 実は命令文としてみると、 わずかに 2 つの命令文であることが分かります。 すなわち、

POE::Component::Server::TCP->new(...中略...);
POE::Kernel->run;

ですね。実質「POE::Component::Server::TCP->new(...中略...);」 だけと言ってもいいでしょう。 この命令文は、

POE::Component::Server::TCP->new
    (
     Port => $Port,
     ClientInput => sub {
	 ... クライアントから受信したデータの処理 ...
     },
     ClientDisconnected => sub {
         ... クライアントとの接続が切れたときの処理 ...
     },
     ClientFilter => POE::Filter::Stream->new(),
    );

という構造になっています。 つまり、クライアントからデータが送られて来たときに呼び出されるルーチンと、 クライアントとの接続が切れたときに呼び出されるルーチンを指定しておけば、 あとは POE がうまくやってくれる、というわけです。簡単でしょう?

リレー サーバにとって「クライアント」というと、 ブラウザか relayagent になります。 クライアントからの TCP/IPセッション一本一本に対して POE が ID を割り振っていて、 この ID を見ればどの TCP/IPセッションで送られて来たデータか分かります。

Perl の非同期I/Oモジュール POE を使って VPN-Warp relayagent を書いてみました」で 解説したように、 クライアントから送られて来た最初のデータが 「GET /KLAB/poll 」で始まっていれば、 そのクライアントは relayagent ですから、 以下のようにその ID ($id) を $PollID に代入しておきます。

	 elsif ($input =~ m@^GET /KLAB/poll @) {
	     if (defined $PollID) {
		 $heap->{client}->
		     put("HTTP/1.1 503 Service Unavailable\r\n\r\n");
		 $heap->{client}->shutdown_output;
		 return;
	     }
	     $PollID = $id;
	     $PollHeap = $heap;
	     $PollBuf = $input;
	     &doPoll;
	 }

同じ TCP/IPセッション (つまり $id == $PollID) で 続いて送られてきたデータは、 以下の部分で処理されます。

	 if (defined $PollID && $id == $PollID) {
	     $PollHeap = $heap;
	     $PollBuf .= $input;
	     &doPoll;
	 }

いずれの場合も、受信したデータはいったん $PollBuf に蓄えた上で、 「doPoll」ルーチンを呼び出します。

一方、ブラウザから送られてきたデータの場合は、 以下のようにセッションID ($SID{$id}) を順に割当てていきます。 「セッション」という単語が何度も出てきてややこしいのですが、 $id が POE が各 TCP/IPセッションに割当てた ID で、 各 TCP/IPセッションそれぞれに、 リレー サーバが 16bit の番号を割当てたのが VPN-Warp で言うところのセッションID ($sid = $SID{$id}) です。

	 else {
	     $SID{$id} = $NextSID;
	     $NextSID = ($NextSID + 1) & 0xFFFF;
	     my $sid = $SID{$id};
	     $Heap{$sid} = $heap;
	     $Buf{$sid} = $input;
	     &doSession($sid);
	 }

同じ TCP/IPセッション (つまりセッションID $sid が $SID{$id}) を通して 続いて送られてきたデータは、 以下の部分で処理されます。

	 elsif (defined $SID{$id}) {
	     my $sid = $SID{$id};
	     $Heap{$sid} = $heap;
	     $Buf{$sid} .= $input;
	     &doSession($sid);
	 }

いずれの場合も、受信したデータはいったん $Buf{$sid} に蓄えた上で、 「doSession」ルーチンを呼び出します。

つまり、relayagent から受信したデータは doPoll ルーチンで、 ブラウザから受信したデータは doSession ルーチンで、 それぞれ処理する、というわけです。 以下の図に示すように、 リレー サーバの役割は、 relayagent から受信した (ブロック化された) データを、 (ブロックを開梱しつつ) ブラウザへ送信し、 またブラウザから受信したデータを、 ブロック化して relayagent へ送ることですから、 doPoll および doSession が何をするためのルーチンか予想できますよね?

VPN-Warp セッション

まず doPoll を見ていきましょう。

sub doPoll {
    do {
	if (! defined $PollHeader) {
	    if ($PollBuf =~ /\r\n\r\n/) {
		$PollHeader = `;
		$PollBuf = ';
		$PollHeap->{client}->put("HTTP/1.1 200 OK\r\n\r\n");
	    }
	}
	return unless defined $PollHeader;

リクエストヘッダを全て読み込んでいない場合 (つまり $PollBuff に空行 \r\n\r\n が含まれていない場合) は、ここで終わりです。
$PollBuf に受信データが追加されて、ふたたび doPoll が呼ばれるまで待ちます。

リクエストヘッダを全て読み込んだ場合は、 $PollBuf からリクエストヘッダ部分を削除した上で、 次に進みます。

	my ($sid, $len, $data) = unpack("nna*", $PollBuf);
	return unless defined $sid && defined $len && $len ne "";

ブロック全体を読み込めていない場合は、ここで終わりです。 $PollBuf に受信データが追加されて、ふたたび doPoll が呼ばれるまで待ちます。 「ブロック」というのは VPN-Warp 用語で、 relayagent とリレーサーバとの通信は、 基本的にこの「ブロック」を単位にして行ないます。 ブロックは次のような可変長のデータです。

    ┌───┬───┬───┬───┬───┬─≪─┬───┐
    │セッションID│ データ長  │  可変長データ   │
    └───┴───┴───┴───┴───┴─≫─┴───┘
          2バイト         2バイト      「データ長」バイト

「セッションID」および「データ長」は、ビッグエンディアンです。 つまり上位バイトが先に来ます。 したがって、上記コードによって $sid, $len, $data にそれぞれ 「セッションID」「データ長」「可変長データ」が代入されます。

なお、データ長が 0 ないし負数の場合は、 「可変長データ」の部分は 0 バイトになります。 このような「可変長データ」がないブロックは、 コントロール用のブロックで、 EOF や Error などのイベントを伝えます。

	if ($len > 32767) {
	    $len -= 65536;
	    $PollBuf = $data;
	    if ($len == -1) {
		&doShutdown($sid);
	    }
	}

$len == -1 のときは、Error を伝えるコントロール ブロックなので、 「doShutdown」ルーチンを呼び出しています。

	elsif ($len > 0) {
	    return unless defined $data && length($data) >= $len;
	    ($data, $PollBuf) = unpack "a${len}a*", $data;
	    if (defined $Heap{$sid}) {
		$Heap{$sid}->{client}->put($data);
	    }
	}

$len > 0 のときは、 $sid で示されるブラウザに対して $data を送信します。 $len == 0 のときは、 EOF を伝えるコントロール ブロックなので、 「doShutdown」ルーチンを呼び出しています。

	else {	# len == 0
	    $PollBuf = $data;
	    &doShutdown($sid);
	}
    } while ($PollBuf ne "");
}

以上を、$PollBuf が空になるまで続けます。

doShutdown はブラウザとの TCP/IPセッションを shutdown するためのルーチンです。

sub doShutdown {
    my ($sid) = @_;
    if (defined $Heap{$sid}) {
	$Heap{$sid}->{client}->shutdown_input;
    }
}

次に doSession です。

sub doSession {
    my ($sid) = @_;
    if (defined $PollHeap) {
	my $req = $Buf{$sid};
	$Buf{$sid} = "";
	for my $block (unpack "(a2048)*", $req) {
	    $PollHeap->{client}->
		put(pack("nna*", $sid, length($block), $block));
	}
    }
}

ブラウザから送られてきたデータを、 2048 バイトずつ区切って「セッションID」「データ長」を 前につけることによってブロック化して、 relayagent に送信しています。

オリジナルの VPN-Warp を使ったことがある方は既にお気付きかも知れませんが、 上記 relayserver.pl は説明を簡単にするために機能をいくつか省いています。 例えば、オリジナルのリレー サーバは、 接続する際はクライアント認証が必須で、 同じクライアント証明書を提示した relayagent とブラウザを 結び付ける機能があるのですが、 上記 relayserver.pl はクライアント認証を行なわないので、 任意のブラウザから接続可能ですし、 同時接続が可能な relayagent は一つだけです。

腕に覚えのあるかたは、 オリジナルの VPN-Warp と同等の機能を実現するには どのような修正を加えればよいか、 考えてみてはいかがでしょうか? そして、 こういうことを考えることが好きなかた、 「いっしょにDSASつくりませんか?

Filed under: システム構築・運用,プログラミングと開発環境 — hiroaki_sengoku @ 06:36
2007年1月22日

Perl の非同期I/Oモジュール POE を使って VPN-Warp relayagent を書いてみました

多数の TCP/IP セッションを同時に維持する必要性などから、 非同期I/O が最近流行りのようです。 何をいまさら、という気もするのですが、 いわゆる「最新技術」の多くが 30年前の技術の焼き直しに過ぎない今日このごろなので、 非同期I/O 技術が「再発見」されるのも、 「歴史は繰り返す」の一環なのでしょう。 スレッドが当たり前の時代になってからコンピュータ技術を学んだ人にとっては、 (古めかしい) 非同期I/O が新鮮に映るのかも知れず、 なんだか「ファッションのリバイバル」に似ていますね。

Perl で非同期I/O 処理を手軽に行なうための枠組みとして、 POE: Perl Object Environment というものが あるようです。 POE を使うと、 あたかもスレッドを使っているような手軽さでプログラミングできます。 試しに VPN-Warp の relayagent を POE を使って書いてみました。 オリジナルの relayagent は C 言語で記述した 4000 行を超える プログラムなのですが、 Perl だと 200 行以下で一通り動くものが書けてしまいました (もちろん C 版の機能を全て実装したわけではありません)。

POE を触るのは今回が初めてだったので、 マニュアルをいちいち参照しながら書いたのですが、 なにせわずか 200 行ですから、 開発はデバッグ込みで 1 日かかりませんでした。 改めて Perl の記述性の良さと開発効率の高さに感動したのですが、 これだけ簡潔に書けてしまうと、 relayagent の機能を解説するときの教材としても使えそうです。

というわけで、 今までブラックボックスだった relayagent の中身の解説を試みたいと思います。 これから POE を使ってみようとする人の参考にもなれば幸いです。

VPN-Warp の relayagent とは、 以下の図のようにリレーサーバと Webサーバの両方へ接続して、 リレーサーバから受取ったリクエストを Webサーバへ中継するプログラムです。 http リクエストを受取ってサービスを行なうのですから、 サーバの一種と言えますが、 外部から接続を受付けるわけではなく、 リレーサーバと Webサーバの両方に対してクライアントとして振る舞う点が ユニークと言えるでしょう。

                      リレー            イントラ         イントラ
ブラウザ ─────→ サーバ ←──── relayagent──→ Webサーバ
            https     443番ポート                        80番ポート

http リクエストを受取って Webサーバへ中継するプログラムというと、 proxy サーバを思い浮かべるかも知れません。 proxy サーバはその名の通り、 ブラウザに対してはサーバとして振る舞います:

                                        proxy            イントラ
ブラウザ ──────────────→ サーバ────→ Webサーバ
                                        8080番ポート     80番ポート

proxy サーバが、ブラウザからの接続を受付けて、 それを Webサーバに中継するのに対し、 relayagent は自身では接続を受付けずに中継する、 という違いがお分かりでしょうか? relayagent は接続を受ける必要がないため、 ファイアウォールの内側など、 外部からアクセスできない場所で使うことが可能になっています。

なお、C 版の relayagent はリレーサーバに対して https で接続するのですが、 Perl 版 relayagent (以下 relayagent.pl) は、 説明の都合上 SSL 暗号化の機能を含んでいません。 実際に使うときは、 stone などで SSL 暗号化して リレーサーバに接続する必要があります。

         リレー                         イントラ         イントラ
         サーバ ←──── stone ←── relayagent──→ Webサーバ
         443番ポート       SSL化        Perl 版          80番ポート

例えば stone を

stone -q pfx=relay,5000005.pfx \
      -q passfile=relay,5000005-pass.txt \
      warp.klab.org:443/ssl localhost:12345 &

などと実行しておき、 relayagent.pl はリレーサーバに接続する代わりに、 localhost の 12345 番に接続します。

では、relayagent.pl を順に見ていきましょう。

#!/usr/bin/perl
use POE qw(Component::Client::TCP Filter::Stream);
my $IdleTimerMax = 6;	# 60 sec
&help unless @ARGV == 2;
&help unless shift =~ m/^(\w+):(\d+)$/;
my ($RelayHost, $RelayPort) = ($1, $2);
&help unless shift =~ m/^(\w+):(\d+)$/;
my ($WebHost, $WebPort) = ($1, $2);
my %WebHeap;
my $PollBuf;
my $PollHeap;
my $PollHeader;
my $IdleTimer;
my $DisconectTime = 0;

$RelayHost, $RelayPort は、 リレーサーバのホスト名とポート番号ですが、
前述したように stone 経由でリレーサーバにつなぐために、
$RelayHost = "localhost", $RelayPort = 12345 などとなります。また、 $WebHost, $WebPort は、 中継先となる (イントラの) Webサーバのホスト名とポート番号です。

続いて、リレーサーバへ接続する (直接の接続先は SSL 化を行なう stone ですが、 煩雑になるので以下 「リレーサーバ」 と略記します) ためのコードです:

POE::Component::Client::TCP->new
    ( RemoteAddress => $RelayHost,
      RemotePort    => $RelayPort,
      Connected     => sub {
	  $PollHeap = $_[HEAP];
	  undef $PollHeader;
	  $PollBuf = "";
	  $IdleTimer = $IdleTimerMax;
	  $PollHeap->{server}->
	      put("GET /KLAB/poll HTTP/1.1\r\nX-Ver: realyagent.pl 0.01\r\n\r\n");
      },
      ServerInput   => sub {
	  $PollHeap = $_[HEAP];
	  $PollBuf .= $_[ARG0];
	  &doPoll;
      },
      Filter        => POE::Filter::Stream->new(),
      Disconnected  => \&reconnectPoll,
    );

POE では、非同期に動く処理を、 処理ごとに分けて書くことができます。 各処理のことを「POEセッション」と呼びます。

上記は、リレーサーバへ接続する POEセッションの生成です。 接続先ホストおよびポートを、 それぞれ $RelayHost と $RelayPort に設定しています。

「Connected => sub {」から始まる部分が、 接続に成功したときに実行するコードです。 細かいところはさておき、 接続したら以下のリクエストをリレーサーバに送る、 という点は読み取れるのではないでしょうか。

GET /KLAB/poll HTTP/1.1
X-Ver: realyagent.pl 0.01

同様に、 「ServerInput => sub {」から始まる部分が、 通信相手 (リレーサーバ) からデータを受信したときに実行するコードです。 受信したデータは、 いったん変数 $PollBuf に溜めておいて、 続いて呼び出す doPoll の中で処理を行ないます。

以上からお分かりのように、 リレーサーバへデータを送るときは、
「$PollHeap->{server}->put(送るべきデータ);」を実行し、 リレーサーバからデータが送られてきた時は、 doPoll で受取ります。 とても見通しが良いですね。

各 POEセッションは、スレッドと同様、同一メモリ空間を共有しているので、 他の POEセッションが変更した変数の値を参照できます。 したがってどの POEセッションでもリレーサーバへデータを送ることができますし、 リレーサーバから受信したデータはどの POEセッションでも読むことができます。

続いて、もう一つ POEセッションを作ります。

POE::Session->create
    ( inline_states =>
      { _start => sub {
	  $_[KERNEL]->delay( tick => 10 );
        },
        tick => sub {
	    if ($IdleTimer > 0) {
		if (--$IdleTimer <= 0) {
		    &sendControl(0, -2);	# keep alive
		}
	    }
            $_[KERNEL]->delay( tick => 10 );
        },
      },
    );
$poe_kernel->run;
exit;

この POEセッションは 10秒に一回、 「tick => sub {」から始まる部分を実行します。 見ての通り、$IdleTimer の値を減らしていって、 0 になったら sendControl を実行します。 $IdleTimer は最初 6 ($IdleTimerMax) に設定されるので、 1 分ごとに sendControl を実行する、という意味ですね。

以上 2つの POEセッションは作成しただけで、まだ走り出していません。
その次の「$poe_kernel->run;」が各 POEセッションを走らせるための呼び出しです。 このルーチンは全ての POEセッションが終了するまで返ってきません。

さて、relayagent はリレーサーバとの接続を常時維持していますが、 無通信時間が続くと (通信経路中にあるファイアウォールなどに) 切られてしまう恐れがあるので、 keep alive ブロックを送信しています。 通信が行なわれていない時間を測るためのカウンタが $IdleTimer というわけです。

通信が行なわれない限り $IdleTimer は減り続け、 1 分経過すると sendControl(0, -2) を呼び出して keep alive ブロックを送信します。 sendControl はこんな感じ:

sub sendControl {
    my ($id, $control) = @_;
    $control += 65536 if $control < 0;
    $IdleTimer = $IdleTimerMax;
    if (defined $PollHeap && $PollHeap->{connected}) {
	$PollHeap->{server}->put(pack("nn", $id, $control));
    }
}

既に説明したように「$PollHeap->{server}->put(データ)」は、 リレーサーバにデータを送る呼び出しですから、 「pack("nn", 0, 65534)」が keep alive ブロックであることが分かります。

「ブロック」というのは VPN-Warp 用語でして、 relayagent とリレーサーバとの通信は、 基本的にこの「ブロック」を単位にして行ないます。 ブロックは次のような可変長のデータです。

    ┌───┬───┬───┬───┬───┬─≪─┬───┐
    │セッションID│ データ長  │  可変長データ   │
    └───┴───┴───┴───┴───┴─≫─┴───┘
          2バイト         2バイト      「データ長」バイト

「セッションID」および「データ長」は、ビッグエンディアンです。 つまり上位バイトが先に来ます。 データ長が 0 ないし負数の場合は、 「可変長データ」の部分は 0 バイトになります。

データ長が 0 ないし負数であるブロックは、 コントロール用のブロックで、 以下の意味を持っています:

データ長意味内容
0EOFWebセッションの終了を要求
-1ErrorWebセッションの異常終了を要求
-2Keep Alive無通信状態が続いたときに送信
-3X OFFWebセッションのデータ送信の一時停止を要求
-4X ONWebセッションのデータ送信の再開を要求

ブラウザ送ったリクエストを Webサーバに届け、 Webサーバのレスポンスをブラウザに返す一連の通信のことを、 ここでは「Webセッション」と呼ぶことにします。 つまり、 VPN-Warp が提供する仮想的な通信路 (トンネル) 上のセッションです。

VPN-Warp セッション

ブラウザがリレーサーバと通信するときの TCP/IPセッションと、 relayagent と Webサーバが通信するときの TCP/IPセッションを対応づけるのが、 セッションID です。 「セッション」という言葉が何度も出てきてややこしいですが、 「セッションID」の「セッション」は、 「Webセッション」の意味です。

リレーサーバと relayagent との間は、 複数の Webセッションを一本の TCP/IPセッションに相乗りさせるので、 そのとき各 Webセッションがこんがらないようにするために ブロックにはセッションID がつけられている、というわけです。

では、次はいよいよ relayagent の中核ルーチンである doPoll です:

sub doPoll {
    do {
	if (! defined $PollHeader) {
	    if ($PollBuf =~ /\r\n\r\n/) {
		$PollHeader = $`;
		$PollBuf = $';
	    }
	}
	return unless defined $PollHeader;
	my ($id, $len, $data) = unpack("nna*", $PollBuf);
	return unless defined $id && defined $len && $len ne "";
	if ($len > 32767) {
	    $len -= 65536;
	    $PollBuf = $data;
	    if ($len == -1) {
		&closeWeb($id);
	    }
	} elsif ($len > 0) {
	    return unless defined $data && length($data) >= $len;
	    ($data, $PollBuf) = unpack "a${len}a*", $data;
	    &reqWeb($id, $data);
	} else {	# len == 0
	    $PollBuf = $data;
	    &closeWeb($id);
	}
    } while ($PollBuf);
}

前述したように、relayagent はリレーサーバに接続したとき、 まず
「GET /KLAB/poll HTTP/1.1」から始まるリクエストヘッダを送ります。 するとリレーサーバは、 次のようなレスポンスを返します:

HTTP/1.1 200 OK
X-Customer: nusers=5&type=1&expire=1169696110&digest=3f6977eceb8c2c43e28e6026b08ba900

そしてこの後 (doPoll において「defined $PollHeader」が真のとき)、 リレーサーバと relayagent は、 前述したブロックを送受信することになります。

「my ($id, $len, $data) = unpack("nna*", $PollBuf);」の部分が、
リレーサーバから受信したブロックを、
「セッションID ($id)」 「データ長 ($len)」 「可変長データ ($data)」 に分解している処理ですね。 続いてブロックの処理が行なわれますが、 コントロールブロックに関する処理は割愛して、 可変長データが付いているブロックの処理を見ていきましょう。 ここで受信した可変長データは、 ブラウザが送信した http リクエストを 2048バイトごとに分割したものです。

つまりリレーサーバは、 ブラウザから https リクエストを受取るたびに「セッションID」を割り振ります。 そして、リクエストをブロックに分割して relayagent へ送信し、 逆に relayagent から受取ったブロックを 同じセッションID ごとに連結して、 http レスポンスとしてブラウザへ送信します。

したがって、 relayagent はリレーサーバから受取ったブロックを 同じセッションID ごとに連結して Webサーバへ中継し、 そのレスポンスをブロックに分割してリレーサーバへ送信すればよいことになります。

同じセッションID ごとに連結して Webサーバへ送信する処理が、 reqWeb です:

sub reqWeb {
    my ($id, $req) = @_;
    if (defined $WebHeap{$id} && $WebHeap{$id}->{connected}) {
	$WebHeap{$id}->{server}->put($req);
    } else {
	POE::Component::Client::TCP->new
	    ( RemoteAddress => $WebHost,
	      RemotePort    => $WebPort,
	      Connected     => sub {
		  $WebHeap{$id} = $_[HEAP];
		  $WebHeap{$id}->{server}->put($req);
	      },
	      ServerInput   => sub {
		  $WebHeap{$id} = $_[HEAP];
		  &sendRes($id, $_[ARG0]);
	      },
	      Filter        => POE::Filter::Stream->new(),
	      Disconnected  => sub {
		  &sendControl($id, 0);
	      },
	    );
    }
}

「POE::Component::Client::TCP->new」によって、 Webサーバと通信するための POEセッションを生成しています。 この reqWeb を実行しているのは、 リレーサーバとの通信を受け持つ POEセッションでしたが、 この POEセッションが新たに POEセッションを生成している点に注意してください。

新しく生成した POEセッションは、Webサーバと接続したとき (Connected)、
「$WebHeap{$id}->{server}->put($req);」を実行して リクエスト ($req) を Webサーバに送信します。 そして Webサーバからレスポンスを受信したとき (ServerInput)、 sendRes を実行します。

sub sendRes {
    my ($id, $res) = @_;
    $IdleTimer = $IdleTimerMax;
    if (defined $PollHeap && $PollHeap->{connected}) {
	for my $block (unpack "(a2048)*", $res) {
	    $PollHeap->{server}->
		put(pack("nna*", $id, length($block), $block));
	}
    }
}

sendRes は Webサーバからのレスポンス ($res) を 2048バイトごとに分割し、 セッションID ($id) とデータ長 (length($block)) を付加した ブロックとしてリレーサーバに送信します。

以上をまとめたのが、relayagent スクリプト です。 ここで解説した機能の他、 http リクエストヘッダの Host: フィールドを書き換える機能も追加しています。

C 版の relayagent に比べると、 http レスポンスの書き換え機能や、 http 以外のプロトコルを通す機能などがない点や、 高負荷時の性能の検証が充分行なえていない点など、 そのまま実運用に使用するには難しい点もありそうですが、 少なくとも プロトタイピングなどの目的 (あるいは教育などの目的) ならば 充分使えそうです。

Filed under: システム構築・運用,プログラミングと開発環境 — hiroaki_sengoku @ 07:13
2006年10月6日

SED 教室

久しぶりに sed の話題を見かけたので、 思わずトラックバックしてみます。

アキバ系!文京区本郷四畳半社長」曰く

最近はSEDとかみんな使わないのかなあ 便利なのに
...
Rubyスクリプトさえ書きたくないときにはSedです。
Sedの過激な使い方については 往年の名著「MS-DOSを256倍使うための本 vol.2」が めっぽう面白い
これこそハックだよなあ

手前ミソながら、sed の過激な使い方にかけては、 SED 教室 も そこそこいい線行っているんじゃないかと自負しておりますが、 いかがでしょうか?

例えば、 SED 教室 第十一回 「正規表現、再論」 で紹介している、 sed で「uniq -c」コマンドを実現するスクリプト:

x
1s/.*/    /
H
y/ 0123456789/11234567890/
G
s/.*\([^0]0*\)\n.*\n\(.*\)[^9]9*$/\2\1/
x
s/\n.*//
$!N
/^\(.*\)\n\1$/!{
  x
  G
  s/\n/  /
  P
  s/.*/    /
  x
}
D

なんてのは、ぱっと見では何をやってるんだか、 まるで分からないこと請け合い ;-)

Filed under: プログラミングと開発環境 — hiroaki_sengoku @ 11:38
2006年9月27日

ssh-agent を screen の中から使う方法

GNU screenバグ報告を行なう ついでに screen-devel ML に参加したら、 次のようなメールが ML に流れてきた:

There is a much simpler solution
http://www.2701.org/archive/200406150000.html

The key is that SSH_AGENT need not point to a socket, it can point to a symbolic link to a socket.

なるほど~

ssh-agent と通信するための UNIX ドメイン ソケット を指す (パス名固定の) シンボリック リンクを作るようにしておけば、 環境変数 SSH_AUTH_SOCK には、そのシンボリック リンクのパス名を 設定しておけば済むので screen の中で ssh を使うとき便利、 というわけである。 つまり、

senri:/home/sengoku % ssh asao
Last login: Sun Sep 10 08:24:20 2006 from senri.flets.gcd.org
Linux 2.6.16.28.

asao:/home/sengoku % echo $SSH_AUTH_SOCK
/tmp/ssh-chKJY25976/agent.25976
asao:/home/sengoku % screen -r

senri で ssh-agent を走らせておいて、 asao へ ssh でログインするさいに、 ForwardAgent を有効にしておくと、 上の実行例のように、SSH_AUTH_SOCK に UNIX ドメイン ソケットの パス名が設定され、このソケットを介して senri の ssh-agent と通信ができる。

ところが、前回のログイン時に使っていた screen を reattach すると、 screen の中では、SSH_AUTH_SOCK の値は、 前回のログイン時のパス名のままである:

asao:/home/sengoku % echo $SSH_AUTH_SOCK
/tmp/ssh-ptnuvb3346/agent.3346

ForwardAgent はログアウトと共に終了するので、 screen の中の SSH_AUTH_SOCK の値は、 ログインするごとに設定し直す必要がある。 これはとてもメンドクサイ。

ログインし直すたびに SSH_AUTH_SOCK の値が変化するから、 このような問題が起きるわけで、 SSH_AUTH_SOCK の値が常に同じなら、 reattach した screen の中でも同じ SSH_AUTH_SOCK の値を使い続けることができる。

すなわち、 SSH_AUTH_SOCK が直接 UNIX ドメイン ソケットを指し示すのではなく、 UNIX ドメイン ソケットを指し示すシンボリック リンクを作成しておいて、 SSH_AUTH_SOCK にはこのシンボリック リンクのパス名を設定しておけばよい。

さっそく ~/.cshrc に次の行を追加した:

set agent = "$HOME/tmp/ssh-agent-$USER"
if ($?SSH_AUTH_SOCK) then
	if (! -S $SSH_AUTH_SOCK) unsetenv SSH_AUTH_SOCK
endif
if ($?SSH_AUTH_SOCK) then
	if ($SSH_AUTH_SOCK =~ /tmp/*/agent.[0-9]*) then
		ln -snf "$SSH_AUTH_SOCK" $agent && setenv SSH_AUTH_SOCK $agent
	endif
else if (-S $agent) then
	setenv SSH_AUTH_SOCK $agent
else
	echo "no ssh-agent"
endif
unset agent

私は、かれこれ 20年近く csh をログイン シェルとして使い続けてきているので、 ~/.cshrc なのだが、今となっては (極めて?) 少数派だろう。 bash など、sh 系をログイン シェルとして使っている場合は、 ~/.profile などに

agent="$HOME/tmp/ssh-agent-$USER"
if [ -S "$SSH_AUTH_SOCK" ]; then
	case $SSH_AUTH_SOCK in
	/tmp/*/agent.[0-9]*)
		ln -snf "$SSH_AUTH_SOCK" $agent && export SSH_AUTH_SOCK=$agent
	esac
elif [ -S $agent ]; then
	export SSH_AUTH_SOCK=$agent
else
	echo "no ssh-agent"
fi

などと書いておけばよいだろう。

Filed under: プログラミングと開発環境 — hiroaki_sengoku @ 07:56
2006年8月28日

screen 4.0.2 のバグ

GNU screen の 最新バージョンである 4.0.2 において、 SJIS な端末で screen を走らせて screen のウィンドウで eucJP を使おうとすると、 1 バイト文字の前に 0x8E が挿入されてしまう。
つまり、 screen のコマンド「encoding eucJP SJIS」 (kanji euc sjis) を実行した場合とか、
あるいは「KJ=SJIS」を指定した端末で「defencoding eucJP」 (defkanji euc) を指定した screen を使うといった場合である。

なぜだろうと思い、ソースを確認すると、encoding.c の 1154 行目あたりが 次のようになっている:

	  if ((0x81 <= c && c <= 0x9f) || (0xe0 <= c && c <= 0xef))
	    {
	      *statep = c;
	      return -1;
	    }
	  return c | (KANA << 16);

え? これってもしかして 2 バイト文字 (全角文字) 以外は 全て 1 バイトカナ (半角カナ) 扱いにしてしまっている?

screen 4.0.2 は、2004年1月27日に公開されていて (私の知る限り) これが最新版だと思うのだが、 このような単純なバグが 2年以上にわたって放置されているとは信じられないので、 すでにパッチが出回っていて、 開発元にも連絡が行っているのではないかと思う。 ご存じの方はご指摘頂ければ幸いである。 しばらく様子をみて、ご指摘が無いようであれば、 念のため開発元にパッチを送ってみる予定。

言うまでもなく、0x80 未満 (最上位ビットが 0) の文字は、 「KANA」扱いしてはいけないので、 上記コードは以下のようであるべきだ:

	  if ((0x81 <= c && c <= 0x9f) || (0xe0 <= c && c <= 0xef))
	    {
	      *statep = c;
	      return -1;
	    }
	  if (!(c & 0x80)) return c;
	  return c | (KANA << 16);

このような修正を加えることにより、例えば ~/.screenrc

defkanji euc
terminfo xterm KJ=sjis
terminfo kterm KJ=euc
terminfo vt100 AB=\E[4%p1%dm:AF=\E[3%p1%dm:KJ=euc

などと設定して、term=xterm な端末 (term は xterm であるが「シフトJIS」な漢字を表示できる) を使うような場合でも、 漢字を正しく表示できるようになった。

Linux の多くは EUC を標準的な漢字コードとして使っているはずで、 その一方で Windows は SJIS が標準的な漢字コードだったはず (最近は UTF8 の方が多い?) なので、 このような SJIS な端末で EUC な screen を使うケースは 決してレアケースではないと思うのだが、 なぜこのようなバグが放置されていたのかとても不思議である (ちなみに私は Windows 上の TeraTerm を EUC の設定で使っていたため、 このバグに今まで気づかなかった)。

このバグは、端末の漢字コードが SJIS で、かつ screen のウィンドウの漢字コードが SJIS 以外の場合 (つまりコード変換が行なわれる場合) に発現する。 全ての 1 バイトコード (0x00 ~ 0x1F のコントロールコードさえも!) の 前に「0x8E」をつけてくれるので、 screen の detach すらできなくなるという凶悪なものである。
念のため screen 4.0.2 に対する patch の形で修正点を示しておく:

--- encoding.c.org	Mon Sep  8 23:25:23 2003
+++ encoding.c	Mon Aug 28 18:11:57 2006
@@ -1151,6 +1151,7 @@
 	      *statep = c;
 	      return -1;
 	    }
+	  if (!(c & 0x80)) return c;
 	  return c | (KANA << 16);
 	}
       t = c;
Filed under: プログラミングと開発環境 — hiroaki_sengoku @ 19:55
2006年5月10日

時刻表ビューアを Wrist PDA に移植して頂いた

拙作 時刻表ビューア を、 Fossil/ABACUSのPalmOSが搭載された腕時計 WristPDA(腕パーム)移植して頂いた

私は、時刻表ビューアのバージョンアップは 2000年8月を最後に行っていない。 その後、Linux Zaurus SL-C700 を使うようになってからは、 Palm 自体を使わなくなってしまっていたので、 時刻表ビューアを見ることさえなくなってしまっていた。

作者自身が忘れていたソフトウェアを、 新しい機種に移植して利用していただけたというのは 大変嬉しいことであり、 オープンソース化しておいて 本当によかったと思う。

Filed under: プログラミングと開発環境 — hiroaki_sengoku @ 08:02
« Newer PostsOlder Posts »