ホーム >> 左脳Script >> linux >> mattows の POST 対応への道

mattows の POST 対応への道


前回書いた Linux での軽量サーバのさらなる改良として、POST対応させてみました。
が、これも単純ではありますがドツボに嵌まったので作業記録を残します。


mattows のコード

mattows の CGI は Apache の CGIモード と同等です。mattows 自体は GET、HEAD アクセスでしか動作しません。

GETアクセスのコードを見ると、CGI 起動プロセスの標準出力を"パイプで乗っ取る"動作となっています。おそらく、どのWebサーバも CGI 動作はこの形でしょう。

case M_GET:
default:
    dup2(fileno(out),1);
    execve(prog,args,cgienv.ptrs);
    break;
FILE*型の out からファイルディスクリプタ番号を取り出し、それを stdout に割り当てる事で、 CGI からの出力をそのまま HTTPクライアントソケットへ流し込んでいます。

HEADアクセスでは、レスポンスのHTTPヘッダのみをクライアントに流さなければならないので、 CGI からの出力を一旦読み込んで、ヘッダ部分だけを返すよう間に割り込んでいるようです。

case M_HEAD :
    if(0 != pipe(pipefds)) {
        myerror(EXIT_FAILURE,"pipe(): %s",strerror(errno));
        break;
    }
    switch(pid = fork()) {
        char buf[4096];

        case -1 :
        myerror(EXIT_FAILURE,
                    "error forking CGI process: %s\n",strerror(errno));
            break;
        case 0 :
            dup2(pipefds[1],1);
            execve(prog,args,cgienv.ptrs);
            break; /* yeah right... */
        default :
            pipefh = fdopen(pipefds[0],"r");
            while(fgets(buf,sizeof(buf),pipefh)) {
                fputs(buf,out);
                if('\r' == buf[0] && '\n' == buf[1])
                    break;
            }
            fclose(pipefh);
    }
    break;
CGI からの出力を受けるために、fork し CGI 実行プロセスを監視する動作をしています。switch文のdefault:以降のコードが、CGI からの出力を受け、ヘッダのみを返す部分です。


Webサーバの動作

そもそも、HTTP の POST アクセスがどのようなデータを処理しなければならないか知らなければなりません。実はワタシはよく判っていませんでした。
GETアクセスと比較してみましょう。改行とEOFは判りやすく記述しました。

GETアクセス:method_get=123456789をパラメータとしている。

GET /test.sh?method_get=123456789 HTTP/1.1\r\n
Host: 192.168.0.2\r\n
User-Agent: Mozilla/5.0 (X11; U; Linux i686; ja; rv:1.8.1.19) Gecko/20081202 Iceweasel/2.0.0.19 (Debian-2.0.0.19-0etch1)\r\n
Accept: text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5\r\n
Accept-Language: ja,en-us;q=0.7,en;q=0.3\r\n
Accept-Encoding: gzip,deflate\r\n
Accept-Charset: Shift_JIS,utf-8;q=0.7,*;q=0.7\r\n
Keep-Alive: 300\r\n
Connection: keep-alive\r\n
Referer: http://192.168.0.2/test.sh\r\n
\r\n[EOF]

POSTアクセス:method_post=123456789をパラメータとしている。

POST /test.sh HTTP/1.1\r\n
Host: 192.168.0.2\r\n
User-Agent: Mozilla/5.0 (X11; U; Linux i686; ja; rv:1.8.1.19) Gecko/20081202 Iceweasel/2.0.0.19 (Debian-2.0.0.19-0etch1)\r\n
Accept: text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5\r\n
Accept-Language: ja,en-us;q=0.7,en;q=0.3\r\n
Accept-Encoding: gzip,deflate\r\n
Accept-Charset: Shift_JIS,utf-8;q=0.7,*;q=0.7\r\n
Keep-Alive: 300\r\n
Connection: keep-alive\r\n
Referer: http://192.168.0.2/test.sh\r\n
Content-Type: application/x-www-form-urlencoded\r\n
Content-Length: 21\r\n
\r\n
method_post=123456789[EOF]

背景が赤い行が「HTTPリクエストコマンド」、緑色の行が「HTTPリクエストヘッダ」、青い行が「HTTPリクエストボディ」です。POSTアクセスには、リクエストボディがありフォームからのパラメータは全てここに表れます。

GETではリクエストのURLに直接パラメータが含まれているます、POSTではヘッダ列挙の後にパラメータが出現しています。また、GETアクセスでは存在しない「Content-Length: 21」が目に付きます。
これは、ヘッダの後に続くHTTPリクエストボディの長さを表していて、ヘッダからデータの大きさが判るようになっているようです。


Webサーバは、リクエスト1行目の先頭が「GET」か「POST」もしくは、そのほかのHTTPコマンドを識別し、適切な処理をする必要があります。

GETアクセス
「GET」コマンドの後の、URLを取り出し該当するファイルをレスポンスとして返送します。
Apache等の高機能サーバは、ヘッダの内容からレスポンスデータを加工する事もあるようです。

CGI 動作の場合、リクエストのURLの「?」以降の文字列をパラメータとして環境変数QUERY_STRINGに設定しておく必要があります。

HEADアクセス
「HEAD」コマンドの後の、URLを取り出し該当するファイルをレスポンスとして返送します。
レスポンスのヘッダを出力する部分までは「GET」と同じです。レスポンスボディをバッサリ切捨て、返送を終了します。

POSTアクセス
「POST」コマンドの後の、URLを取り出し該当するファイルをレスポンスとして返送します。
ヘッダにある「Content-Length」からリクエストボディを読み取り、パラメータやデータとして扱います。

注意しなければならないのは、CGI 動作の際「リクエストボディを CGI の標準入力に宛てる必要がある」という事。CGI は、POSTデータを標準入力から取り込むという仕様があるようです。
「perl cgi post」等で検索すると判りますが、POSTアクセスの場合は自前でデータを取り込む必要があるのです。
PHPでは、このような事をする必要がありませんが、それはPHP実行エンジンがスクリプトを実行する前に標準入力からデータを取り込んでいる為。Perl では真面目に stdin から読み込まなければなりません。

おもだった HTTP コマンドを挙げましたが、実際は POST と GET の2つに対応していれば、Webサーバとして十分な動作をします。


思いつき実装

mattows のコードの CGI 駆動部分 static int handle_cgi() を、ちょっと弄くって対応させて見ましょう。

FILE* out は、socket から fdpoen() で作成した FILE* 型です。オープンする際に "r+" で入出力に対応させていますので、そのまま dup2() で割り当ててみました。

case M_GET:
default:
    dup2(fileno(out),0);
    dup2(fileno(out),1);
    execve(prog,args,cgienv.ptrs);
    break;
しかし、これでは動きません。

そこで、FILE* を作成する際に、入力専用と出力専用の FILE* に分ける事で対応しました。
static int handle_connection()の一部。

    FILE *s_in = fdopen(fd,"r");
    if(!s_in) return ret;       /* very bad, don't bother with messages */
    FILE *s_ot = fdopen(fd,"w");
    if(!s_ot)
    {
        fclose(s_in);
        return EXIT_FAILURE;        /* very bad, don't bother with messages */
    }
static int handle_cgi()変更部分。

case M_GET:
default:
    dup2(fileno(s_in),0);
    dup2(fileno(s_ot),1);
    execve(prog,args,cgienv.ptrs);
    break;
これで動くハズです。


以下のようなシェルスクリプトで CGI を試してみました。

#!/bin/sh
echo "Content-Type: text/html"
echo "Pragma: no-cache"
echo "Cache-control: no-cache"
echo "Expires: Wed, 10 Jan 1990 01:01:01 GMT"
echo ""

echo "<html><body>"

echo "<hr><form method='get'><input name='method_get'/></form>"
echo "<br>$QUERY_STRING"
echo "<hr><form method='post'><input name='method_post'/></form>"
echo "<hr>"
echo "ContentLength:"$CONTENT_LENGTH"<BR>"

if [ "$REQUEST_METHOD" = "POST" ]
then
    echo "<hr>"
    read post
    echo $post"<br>"
fi

echo "</body></html>"

すると、 POST アクセスで固まってしまいます。具体的には read コマンドでブロックされてしまうのです。

これは、socket からの入力をそのまま CGI のstdin に割り当てた為、入力の EOF を検知出来ずに延々と待っている為でした。正しくは、「read コマンドで CONTENT_LENGTH 文字までを読み込む」という処理をしなければならなかったのです。

普通のシェルならば、read コマンドは読み取る文字数を指定できるのでそのように記述すれば問題ないのですが、組み込み環境などで、この文字数指定ができない場合があるのです。自分が関わった案件での環境が、まさにそのような状況(コマンドを busybox に頼っていた)でした。


ところが、この実験スプリプトは thttpd では固まらずに意図通りの動作をします。何故なんでしょうか?


read コマンドは、入力ストリームが終了するまで読み取りを続ける動作をするので、thttpd からの入力が「HTTPリクエストボディを出力しきったところで、しっかり終了し閉じている」と考察できます。対して、改良 mattows では、閉じるとかそんな事はしていません。dup2() でストリームを繋いだだけです。

つまり、mattows でこれを実現する為には、「繋ぎっぱなし」ではなく「必要な出力をした後にストリームを閉じる」動作をしなければなりません。


パイプ

アプリケーションからプロセスを起動し実行する際に、標準入出力を乗っ取るサンプルコードは以下のページが判り易く参考になりました。
→参考:http://keicode.com/note/lin07.php

実際、ほとんど上記のページのコードの流用でPOST動作を実現できるのですが、ここで嵌まったのでした。

CGI を実行する子プロセスで、標準入出力を乗っ取る部分のコードはほぼこのような形になります。

    int p_in[2], p_out[2];  /*  標準入出力乗っ取り用パイプ2個 */

    if(EXIT_SUCCESS!=pipe(p_in))
    {
        myerror(EXIT_FAILURE,"in pipe(): %s",strerror(errno));
    }
    else if(EXIT_SUCCESS!=pipe(p_out))
    {
        myerror(EXIT_FAILURE,"out pipe(): %s",strerror(errno));
    }
    else if((pid=fork())==-1)
    {
        myerror(EXIT_FAILURE,"error forking CGI process: %s\n",strerror(errno));
    }
    else if(pid==0)
    {   /* 子プロセス  */
        dup2(p_in[0],0);    // stdin
        dup2(p_out[1],1);   // stdout

        close(p_in[0]);
        close(p_in[1]);
        close(p_out[0]);
        close(p_out[1]);

        execve(prog,args,cgienv.ptrs);
pipe()でパイプを作り、fork()で子プロセスを作り、標準入出力を乗っ取る、という流れです。子プロセスは CGI を実行し、親プロセスは子プロセスの標準入出力を管理します。

しかし、このコードで POST アクセスをすると、CGI 実行時に標準入力の stdin、標準出力の stdout ともに「異常なディスクリプタ」というエラーメッセージを吐いて正常に動作してくれませんでした。
そこで、「pipe()で作成されたパイプのファイルディスクリプタが異常なのか」と、以下のコードでログに出力させてみました。

    {   /* 子プロセス  */
        mywarn("pipe: i0:%d i1:%d o0:%d o1:%d\n",p_in[0],p_in[1],p_out[0],p_out[1]);

        dup2(p_in[0],0);    // stdin
すると、ログに出力されたのは

pipe: i0:1 i1:3 o0:4 o1:5
と、やや規則性のないディスクリプタ番号でした。

この番号で処理を追うと、「クローズされていない、生きているファイルディスクリプタに別のファイルディスクリプタを割り当てようとしていた」事が発覚。

サンプルを綺麗に踏襲したというのに(一般的に言うコピペ)なぜサンプルだと動作するのか。

この考察はさほど難しくありませんでした。
上記で紹介したページのサンプルと違うのは、「パイプ作成前に、親プロセスの標準入出力がクローズされていた」事でした。Linux のシステムは、空いているファイルディスクリプタを若い方から順に割り当てます。パイプ作成前に標準入出力がクローズされれば、当然、標準入出力の固有番号0、1からファイルディスクリプタを割り当てようする訳で、当然の動作だったとも言えるのです。その割には、0に割り当てがないのはよくわからないのですが。


CGI実行ルーチン

最終的に、CGI実行ルーチンはこのような形となりました。

static int handle_cgi(char *prog, char *query, reqtype method,
    FILE *s_in, int cnt_len, FILE *s_out, struct  sockaddr_in *peer)
{
    int result = EXIT_FAILURE;
    mywarn("Executing %s\n",prog);
    HTTPMSG(s_out,M200);
    fflush(s_out);

    int pid;
    int p_in[2], p_out[2];  /*  標準入出力乗っ取り用パイプ2個 */

    if(EXIT_SUCCESS!=pipe(p_in))
    {
        mywarn("in pipe(): %s",strerror(errno));
    }
    else if(EXIT_SUCCESS!=pipe(p_out))
    {
        mywarn("out pipe(): %s",strerror(errno));
    }
    else if((pid=fork())==-1)
    {
        mywarn("error forking CGI process: %s\n",strerror(errno));
    }
    else if(pid==0)
    {   /* 子プロセス  */
        /*  環境変数作成  */
        buildenv(&cgienv,method,prog,query,inet_ntoa(peer->sin_addr));
        /*  pipe(2)で得られるファイルディスクリプタは、stdin,stdoutなどの番号0、1になる
                可能性があるので、複製とクローズの順番に注意しないと、正しいパイプ接続ができない    */
        close(p_in[1]);
        close(p_out[0]);
        dup2(p_in[0],0);close(p_in[0]);     //  stdin
        dup2(p_out[1],1);close(p_out[1]);   //  stdout
        /*  CGI起動   */
        char *args[2];
        args[0] = prog;
        args[1] = 0;
        execve(prog,args,cgienv.ptrs);
        /*  ここに来たら本当はエラー    */
        mywarn("Execute error \"%s\":%s\n",prog,strerror(errno));
        /*  親プロセスが止まらないよう標準入出力を閉じる  */
        close(0);
        close(1);
        /*  親プロセスのストリームをフラッシュせずに終了  */
        _exit(0);
    }
    else
    {   /*  親プロセス */
        FILE *pipefh;
        char buf[4096];
        size_t sz;

        close(p_in[0]);
        close(p_out[1]);
        /* 子プロセスの標準入力へデータを流す */
        if(cnt_len>0)
        {
            pipefh = fdopen(p_in[1],"w");
            if(pipefh)
            {   /* post body */
                while(cnt_len--)
                {
                    int c = fgetc(s_in);
                    if(c==EOF)  break;
                    fputc(c,pipefh);
                }
                fclose(pipefh);
            }
            else
            {
                mywarn("CGI stdin open error \n",prog);
            }
        }
        /*  closeしないとCGIによっては永久にEOFを待ってしまう    */
        close(p_in[1]);

        /* 子プロセスの標準出力からデータを取得 */
        pipefh = fdopen(p_out[0],"r");
        if(pipefh)
        {
            /*  header  */
            while(fgets(buf,sizeof(buf),pipefh))
            {
                fputs(buf,s_out);
                if('\r' == buf[0] && '\n' == buf[1])    break;
            }
            /*  body    */
            if( method==M_GET || method==M_POST )
            {
                while((sz = fread(buf,1,sizeof(buf),pipefh))>0)
                {
                    fwrite(buf,1,sz,s_out);
                }
            }
            fclose(pipefh);
            //
            fflush(s_out);
            result = EXIT_SUCCESS;
        }
        else
        {
            mywarn("CGI stdout open error \n",prog);
        }
    }

    return  result;
}

mattows はクライアント毎にプロセスを fork する為、CGI 実行では一つのリクエストで2個のプロセスが走ります。

実はこのコードは、HTTPリクエストのタイムアウトを考慮していません。POSTリクエストの最中に、クライアントがなんらかのエラーで停止すると、接続が切れない限り永久に止まったまま(データ受信待ち状態)になってしまいます。


用途

小さなコード、小さなバイナリで、Webサーバとしての動作は出来るようになりました。が、これを外に向けて公開してはなりません。外部からの攻撃に対してなんの考慮もされていないコードだからです。

残念な事ですが、現時点では閉じたネットワークでの実験開発レベルでしかありません・・・


せめて「設定に気をつければ自宅サーバくらいには使える」程度にしたいものです。



トラックバック(0)

トラックバックURL: http://n-yagi.0r2.net/sanoupulurun/mt-tb.cgi/257

コメントする

ホーム >> 左脳Script >> linux >> mattows の POST 対応への道

アーカイブ

このブログ記事について

このページは、n-yagiが2010年6月11日 19:04に書いたブログ記事です。

ひとつ前のブログ記事は「軽量Webサーバーmattows」です。

次のブログ記事は「Webサーバへの攻撃とは?」です。

最近のコンテンツはインデックスページで見られます。過去に書かれたものはアーカイブのページで見られます。

Creative Commons License
このブログはクリエイティブ・コモンズでライセンスされています。