nginx+fastcgi+pukiwiki

nginx で pukiwiki を起動する時、web 上のレシピによっては fastcgi_param の設定が適切にできず、アンカーの張るパスがファイルシステム上のフルパスになってしまう場合がある。
正:

fastcgi_param  SCRIPT_NAME        $fastcgi_script_name;
fastcgi_param  SCRIPT_FILENAME    $document_root$fastcgi_script_name;

誤:

fastcgi_param  SCRIPT_NAME        $document_root$fastcgi_script_name;
fastcgi_param  SCRIPT_FILENAME    $document_root$fastcgi_script_name;

要は、phpinfo() の環境変数などが正しくセットされればいいわけで、正常な例とにらめっこしながら設定してみると良いのでは。

設定は、fastcgi_param で正しくやっておけば、nginx.conf だったり各サイトの server ディレクティブ内でだったりで設定しなくていいはず。

Indexes の代わりをする php スクリプト

またつまらぬものを作ってしまった…

自分で管理しているサーバでは不要だが,会社のサーバなど管理者権限がなく,でもディレクトリ内のファイル一覧を見たいパスで,Indexes オプションの代わりをしてくれる php スクリプトを作ってみた.
自分のブラウザ環境で,ある httpd サーバのファイル一覧画面を参考に作ったものなので,多少のフォーマット変更は各人でヨロ.

<html>
<head>Index of <?php
if($_SERVER['SCRIPT_NAME']==$_SERVER['REQUEST_URI']) {
    if(dirname($_SERVER['REQUEST_URI'])=='/')  print '/';
    else print dirname($_SERVER['REQUEST_URI']).'/';
} else {
    print $_SERVER['REQUEST_URI'];
}
?>

Index of /


<?php
foreach (scandir('.') as $val) {
    if ($val=='.') continue;
    elseif ($val=='..') print "<a href=\"../\">../\n";
    else {
        print "<a href=\"$val\">$val";
        for ($i=51-strlen($val);$i>0;--$i) print ' ';
        $st=stat($val);
        print date('d-M-Y H:i',$st[9]);
        for ($i=20-strlen("$st[7]");$i>0;--$i) print ' ';
        print $st[7]."\n";
    }
}
?>

</body> </html>

特定ドメインでアクセスされた場合のみ、移転後ページへのリンクを張った移転メッセージを表示

前使っていたドメインから現在のドメインへリダイレクトをしていたが、どうにもアクセスが完全には途絶えていないらしい。
んで、リダイレクトしているホストやリファラから、古いドメインへリンクしているサイトを特定するために、また移転したよメッセージを表示するために、ワンクッションはさむことにした。←ほぼ検索エンジンか更新されていない掲示板等であることが判明したので,rewriteはやめた
なんとなくのイメージだけで作業開始したものの、実際に作業してみるとハマりポイントがいくつかあったため、メモに残す。

やったこと

  • 旧ドメイン配下の任意ページ(存在の有無によらず)へのアクセスがあった場合、移転メッセージを提示する。
  • 同居する他のドメイン名で80番ポートにアクセスが飛んだ時は、明らかに存在するはずのルートへのリクエストのみ403を返し、他のリクエストの場合は404を返す。
  • 移転メッセージには、http[s]://新ドメイン/元リクエストのパス?元リクエストのQUERY_STRING へのリンクを貼っておく。
  • アクセスログを mysql に残す。

ハマりポイント

任意ページへのアクセスの受け方
分かりにくいけど,これはもう使ってない方法.
任意ページへのアクセスを、上の処理を行なうスクリプトへ元のGET文字列も含めて飛ばすのは意外と難しい。
.htaccess を使って、Rewrite でリクエストを書き換えると簡単にできる。ただし REFERER はこの時点で死ぬので、REFERER を取りたい場合は他の手段を講じる必要がある。
具体的な記述は、例えば

RewriteEngine On
RewriteCond %{HTTP_HOST} ^(old\.domain\.com)(:80)?
RewriteCond %{HTTPS} off
RewriteRule ^(.*)$ http://redirect.domain.com/?$1 [QSA,R=301,L]

といった感じ。
QSA は QUERY_STRING を最後に付与する指示。

php での HTTP ヘッダの返し方
php で HTTP ヘッダを返して、特定のアクセスの場合に 404 とか 403 を返す方法はいくつかある。
詳しくは php 公式ページのリファレンスを見るとして、私の環境では次のコードがうまく動いた。
あらゆる出力 (HTML の DOCTYPE や XML 宣言を含む) より前に記述しないと意味がないことに注意。

header("HTTP",true,404);
header("Content-type: text/html; charset=iso-8859-1");
include('./404.php');

404.php は、httpd の 404 ドキュメントを模したファイルで、上の .htaccess を用いた場合は QUERY_STRING に アクセスパスがついてくるので、展開して使えば、それっぽく表示できる。
apache2 の場合、404 や 403 はブラウザの設定に依らず charset が iso-8859-1 で返っていたので、ヘッダに入れた。

今時のmysql

なんか php 公式のリファレンスに mysql は古いから mysqli を使え、と書かれていたので、使うことにしたが、ちょっと梃摺った。

$mysqli = new mysqli("DBHOST", "DBUSER", "DBPASS", "DBNAME");
$mysqli->query($query);

mysqli では、特段の理由がない限りはセッションの close をしないらしい。
$query は使いたい SQL をそのまま書いたもの。

いざ文書にしてみると、迫力のない tips だな。
他にも元アクセスのパターンによって何度か print デバッグしないといけないと思うけど、頑張れ!

apache2 の設定メモ

毎回,OS を入れ直す度に忘れるので,今回こそはきちんとメモるぞー.
記事の趣旨が趣旨なので,この記事は随時アップデートされると思う.

前提

LAMP サーバが入った状態で Ubuntu server をインストールしたことが前提.
自分しか使わないサーバなので,mod_php5 で勘弁する.
(この後の予定) SSL 対応と,vhost 対応.←済

設定

  • dir.conf を編集して,いらん index ファイル指定を消す.
  • userdir を使うので,有効化する.
    # a2enmod userdir
    良く考えたら,VirtualHost を使うので,なくて良い気がする.
  • php5 自体は有効化されているが,userdir では無効になってるので,有効化する.
    php5.conf を開いて,userdir のディレクティブに記述されている,php_admin_value engine Offをコメントアウト.
  • directory 内のファイルリストが見えるのは困ることがあるので,デフォルトオフにしておく.
    userdir.conf の Indexes を削除.
  • 多言語対応は,userdir.conf の MultiViews を有効にしておいて,hoge.html.ja hoge.html.en とかを用意する.
    ユーザーが hoge.html にアクセスした時,その人の言語優先度に従って表示するファイルが選択される.
    更新し忘れが多発しそうなので,管理はそれなりに考えないといかんかも.
  • favicon の設定に,rewrite モジュールを使う.
    # a2enmod rewrite
    ただし,RewriteEngine を使う場合,AllowOverride FileInfoと,Options SymLinksIfOwnerMatch辺りを
    許可しておかなければならないので,要注意.
  • DocumentRoot が /var/www とかになっているため,絶対パスで / から指定しているリンクが悉く死ぬ.
    DocumentRoot を変更しても良いけど,最終的に userdir でアクセスさせないので, sites-available に VirtualHost の設定を書いた方が良い.
  • VirtualHost ディレクティブの ErrorLog は,ログ出力先.
    LogLevel は,ログ出力レベル.指定するレベルはググること.
    CustomLog は,アクセスログの出力先.
  • VirtualHost ディレクティブでは,ServerName を指定しないと apache2 が warn を吐くが,
    設定していたとしても,サーバの hostname.domainname と一致していないと同様に warn を吐く.
    設定は,/etc/hostname を FQDN で書いてしまい,networking の restart でとりあえず動く.(←動かない時は,再起動したら動いたりする)
  • ServerTokens ディレクティブや ServerSignature ディレクティブは,conf.d/security に書かれているので,この後に読まれるよう記載する.
    ServerTokens では HTTP のヘッダの出力のサーバ情報の出力を制御でき (ErrorDocument も,この制御された token を出力する),
    ServerSignature ディレクティブでは ErrorDocument 下部のサーバ情報の出力を制御できる.
  • php のヘッダー出力の抑制は,php.ini で行なう.
    /etc/php5/apache2/php.ini の expose_php を Off とする.
  • digest 認証はモジュールをロードさせないといけない.
    # a2enmod auth_digest
  • deflate モジュールでファイルを gzip 圧縮して転送できるが,転送サイズがファイルサイズより大きいことがある.
    その時は,SSI とか PHP とかで include していないか思い返してみること…
    これでハマった.
  • SSL対応。
    署名要求ファイルを当該サーバ上で生成して、証明機関に送信して証明書が作成される。
    まずは、署名要求ファイルの作成から。

    1. 秘密鍵を作成する。
      openssl genrsa -aes256 2048 > server.key

      秘密鍵は、パスフレーズで保護する。
      server.key が秘密鍵。

    2. 秘密鍵を使って、署名要求ファイルを作成する。
      openssl req -new -key server.key > server.csr
      openssl req -new -sha256 -key server.key -out server.csr # sha-2署名を要求する
      秘密鍵のパスフレーズを求められるので、入力する。
      証明書へ埋め込みたい情報の入力が求められるので、入力する。
      とりわけ、Common Name は使いたい web site のホスト・ドメイン名と一致させておくこと。
      server.csrが署名要求ファイル。
  • 続いて、StartSSL を例にドメイン名の検証 (validation) を行なう。
    詳しくは、[無料 SSL 証明書 StartSSL を使う](http://futuremix.org/2009/02/startssl)などを参照。

    1. サインアップから名前とか住所とかを登録。
    2. クライアント証明書をインストール。(次回からのログインにはクライアント認証が必須)
    3. ドメイン名検証を通過させる。独自ドメインのメールか、ドメイン登録に使っているメールの受け取り環境が必要。
  • web サーバの検証を行なう。
    1. 登録済みサブドメインの失効は有料といわれたので、ここで手順は中止。HW 交換したら、全てのキーを保存しておかんといかんなぁ。。。
    2. 別ドメインを建ててみる.
      CSR ファイルを作る画面が出てくるが,ローカルで作成しているので skip する.
    3. トップレベルドメインは,検証済のドメインを選択する.
      次にサブドメインを入力させられるので,検証させたいサブドメインを入力する.
    4. Save Certificate という画面で,crt ファイルの中身が出てくるので,
      コピーして server.crt とかのファイル名でテキストファイルにペースト.
      改行コードは LF.
    5. 同じ画面からリンクされている「intermediate」と「root」の CA certificates をダウンロードする.
  • web サーバの設定
    1. /etc/apache2/ports.conf を編集し,mod_ssl.c のモジュール設定の部分に,
      NameVirtualHost *:443
      とか書いておく.Listen が書かれてなければ,Listen 443 も書いておく.
    2. これまで準備したファイルのうち,必要なものを配置.
      /path に放り込んだとすると,次のファイルが必要.
      /path/server.key
      /path/server.crt
      /path/sub.class1.server.ca.pem (← intermediate CA Certifications)
      /path/ca.pem (← root CA Certifications)
    3. SNI を定義しているファイル (site-enabled にあると思われる) の中で,
      ssl 用の設定を書く.
      その中で,配置したファイルを指定する.ついでに簡単な雛形も載せておく.

      
              ServerAdmin     MAIL_ADDR
      
              DocumentRoot    ROOT_DIR
              ServerName      DOMAIN_NAME
              SSLEngine       on
              SSLCertificateFile      /path/server.crt
              SSLCertificateKeyFile   /path/server.key
              SSLCertificateChainFile /path/sub.class1.server.ca.pem
              SSLCACertificateFile    /path/sslfiles/ca.pem
              
                      ...
              
              
                      ...
              
      
              ...
      
      
  • 上述のままだと,apache 起動時に server.key のパスフレーズを求められる.
    自動リブートとかさせていると問題があるので,解除する.

    # openssl rsa -in  server.key.org > server.key

-- 追記
いつからか,apache2 の再起動時に,以下のエラーを吐くようになった.
いろいろと対処しているうちに名前解決が怪しいと気付き,syslog を見て原因が分かった.
ホスト名を変更した時に,旧ホスト名を現ホスト名の CNAME にしたにも関わらず,ローカルに立てている DNS の SOA レコードとか NS レコードとかもろもろを書き換え忘れていたことが原因.

httpd: apr_sockaddr_info_get() failed for hoge.hadacchi.com
httpd: Could not reliably determine the server's fully qualified domain name, using 127.0.0.1 for ServerName

どうやらこれは,二つのエラーを意味しているようだ.
多分
1行目:ServerName の設定がないから自分で取ろうとしたけど,うまくいかないぜ!
2行目:なんか,ちゃんとした答えが得られないから,これこれを ServerName に使うぜ!
とうことなんだろうと思う.対応とその結果を書いておく.

  • 対処1:自分の使用しているホスト名 (# hostname -f で表示されるものの FQDN) を /etc/hosts の 127.0.0.1 の別名として併記する.
    ただし,今回のように bind の設定ミスがある場合は,hostname -f でエラーが返る.

    • 結果1:1行目のエラーのみ消え,2行目の 127.0.0.1 の部分が,/etc/hosts に設定したホスト名となる
  • 対処2:(VirtualHost を使っているとしても) httpd.conf あたりに,ServerName 定義行を追記する.値は,デフォルトで使いたいサーバ名.
    通常は,最初に apache に読み込まれる VirtualHost 名.

    • 結果2:対処1をしなくても,エラーが両方消える.
      ServerName の設定が静的に記述されているので,この一連の動作自体が発生しないのだと思う.
  • 対処3:localhost に DNS サーバを立てる.
    • 対処1,2をしなくても,エラーが両方消える.

spammer counter v4 と viewer v2

当サイトでは, spammer と思しき IP アドレスからのアクセスを禁止している.しかし,禁止後もアクセスを続けるしつこい spammer が多い.
特にしつこい spammer は何か,あっさり消えた spammer は誤爆かも知れない,という観点から,アクセス禁止後も引き続き継続するアクセスを, IP アドレス毎にカウントしたい.
ということで,前回の spammer counter v3 では, MySQL を使って簡易に実装した.

今回は, IP アドレス毎にソートできるようにした.
これは,

  • ある程度の IP アドレスのレンジでアクセスを繰り返す spammer をネットワークアドレスで弾いて,.htaccess をスリムにしたい.
  • ネットワークアドレスによる指定で,過剰に絞りすぎているレンジがあれば,アクセス禁止を解きたい.

という狙いから, IP アドレスでソートをかけ,可読性を高めるためだ.

spammer counter v4

ソースコードに入る前に注意

.htaccess へ ErrorDocument 403 を追記する場合,その飛ばし先のファイルへのアクセスは可能とすること.
さもなくば, 403 の転送で無限ループが発生する.
具体的には,.htaccess へ下記を追記.hogehoge.phpの部分は,設置したphpファイル名に変更すること.

ソースコード

<?php
header('HTTP/1.1 403 Forbidden');
header('Content-Type: text/html; charset=iso-8859-1');
?>
<!DOCTYPE HTML PUBLIC '-//IETF//DTD HTML 2.0//EN'>
<HTML><HEAD>
<TITLE>403 Forbidden</TITLE>
</HEAD><BODY>
<H1>Forbidden</H1>
<?php printf('You don't have permission to access %s\non this server.',
             htmlentities(strip_tags($_SERVER['REQUEST_URI']))); ?><P>
<HR>
<ADDRESS><?php
$e = explode(' ',$_SERVER['SERVER_SOFTWARE']);
printf('%s Server at %s Port %d',$e[0],$_ENV['SERVER_NAME'],$_ENV['SERVER_PORT']);
?></ADDRESS>
</BODY></HTML>
<?php
// MySQL
$mySqlHost = 'xxx.xxx.xxx';
$dbname    = 'database_name';
$user      = 'user_name';
$password  = 'password';
$table     = 'spam_counter';

// connecting
$mobj = mysql_connect($mySqlHost,$user,$password);
if ($mobj == FALSE) { die('broken');}

// count IP addr
$addr    = $_SERVER['REMOTE_ADDR'];
$addr_pt = explode('.',$addr);
if (count($addr_pt)<4) { die('wrong address...');}

$sql_str = sprintf('insert into %s (ipaddr,number,ip1,ip2,ip3,ip4) '
                   .'values ('%s',1,%s,%s,%s,%s) on duplicate key update number=number+1;',
                   $table,$addr,$addr_pt[0],$addr_pt[1],$addr_pt[2],$addr_pt[3]);

mysql_select_db($dbname,$mobj);
$done=mysql_query($sql_str);
mysql_close($mobj);
if ($done == FALSE) { die('no response...');}

?>

説明 (前回からの差分)

IP アドレスを分割して,格納しているだけ.
カウントさせるために,主キーたる IP アドレスは保持.

spammer viewer v2

当然,テーブルが変更になるため, viewer にも変更が加わる.

ソースコード

<?php require_once './php/head.inc.php'; ?>
<!DOCTYPE html PUBLIC '-//W3C//DTD XHTML 1.0 Transitional//EN'
    'http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd'>
<html xmlns='http://www.w3.org/1999/xhtml' xml:lang='ja' lang='ja'>
 <head><title>spammer viewer v2</title></head>
 </body>
 <h1>spammer viewer v2</h1>
 <table style='border: black solid 1px;'>
 <tr>
  <th>IP addr
   <a href='<?php echo $_SERVER['PHP_SELF'];?>?mode=ip&order=asc'>▲</a>
   <a href='<?php echo $_SERVER['PHP_SELF'];?>?mode=ip&order=desc'>▼</a>
  </th>
  <th># of access
   <a href='<?php echo $_SERVER['PHP_SELF'];?>?mode=num&order=asc'>▲</a>
   <a href='<?php echo $_SERVER['PHP_SELF'];?>?mode=num&order=desc'>▼</a>
  </th>
 </tr>
<?php
// MySQL
$mySqlHost = 'xxx.xxx.xxx';
$dbname = 'database_name';
$user = 'user_name';
$password = 'password';
$table = 'spam_counter';

$mode      = $_GET['mode'];
$order     = $_GET['order'];

if ($mode == 'num' and $order=='asc') { $oby = ' order by number ASC;';}
elseif ($mode == 'num') { $oby = ' order by number DESC;';}
elseif ($mode == 'ip' and $order=='desc') { $oby = ' order by ip1,ip2,ip3,ip4 DESC;';}
elseif ($mode == 'ip') { $oby = ' order by ip1,ip2,ip3,ip4 ASC;';}
else { $oby = ';';}

// connecting
$mobj = mysql_connect($mySqlHost,$user,$password);
if ($mobj == FALSE) { die('broken');}

mysql_select_db($dbname,$mobj);

$done=mysql_query('select * from '.$table.$oby);
//$done=mysql_query('select * from '.$table.' order by CAST(ipaddr AS SIGNED);');
mysql_close($mobj);
if ($done == FALSE) { die('no response...');}

$total = 0;
$num_host = 0;
$ostr = '';
while ( $row = mysql_fetch_row($done)) {
    $ostr .= sprintf('  <tr><td>%s</td><td style=\'text-align:right;\'>%d</td></tr>\n',$row[0],$row[1]);
    $total = $total + (int)$row[1];
    ++$num_host;
}
$ostr .= sprintf('  <tr><td>total</td><td style=\'text-align:right;\'>%d</td></tr>\n"
                 .'  <tr><td># of hosts</td><td style=\'text-align:right;\'>%d</td></tr>',$total,$num_host);
print $ostr;

?>
 </table>
 </body>
</html>

説明 (前回からの差分)

分割した IP アドレスの前から 1Byte 毎の値で,複数キーによるソートをかけているだけ.

おまけ

SQL文

テーブル作成.

create table spam_counter
(
ipaddr varchar(16) not null,
number int unsigned not null,
ip1 tinyint unsigned not null,
ip2 tinyint unsigned not null,
ip3 tinyint unsigned not null,
ip4 tinyint unsigned not null,
primary key (ipaddr)
);

スパマーの REMOTE_ADDR

spammer の中でも,頻繁にアクセスのあるホストをアク禁にしているのは,トップページからもリンクしているアクセス禁止リストの通りだが,
その集計結果を spammer viewer | hadacchi blog に出力させてみた.
このうち,どの程度が踏み台にされている一般PCなのかは,よく分からない.
1つ1つ,whois を引けば分かるように思うが,時間がない.

これだけ弾いていても,現在なお 100件/日 の spam 投稿がなされる.
特に今月に入って, Akismet が検出できない記事が増えている (2件/日) ので,困っている…

逆に,投稿したハズなのに表示されないということがあれば,誤検出している可能性が高いので,右下にあるフォームから苦情を飛ばして欲しい.
これだけスパムが届くと,誤検知がないか,チェックしていられない.

spammer counter v3

IP アドレスでアクセスを禁止している spammer が,その後どれだけアクセスを続けているかをカウントする php コードを,今回は MySQL を使って実装した.
まず最初に注意点. ErrorDocument 403 で php ファイルへ飛ばす場合,その php ファイルへのアクセスは可能とすること.
さもなくば, 403 の転送で無限ループが発生する.
具体的には,.htaccess へ下記を追記.hogehoge.phpの部分は,設置したphpファイル名に変更すること.

<Files hogehoge.php>
allow from all
</Files>

spammer のアクセスが集中している時など,どうしても
spammer counter v2
では,ファイル書き込みに失敗するようだ.
その結果,度々ファイルがクリアされてしまっていた.

んで,よく考えたらこのサイトでは MySQL を使う blog が2つも動いていることを思い出したので,
DB で実装することにした.
とても楽チン.

そのコードは,こんな感じ.

<?php
header('HTTP/1.1 403 Forbidden');
header('Content-Type: text/html; charset=iso-8859-1');
?>
<!DOCTYPE HTML PUBLIC '-//IETF//DTD HTML 2.0//EN'>
<HTML><HEAD>
<TITLE>403 Forbidden</TITLE>
</HEAD><BODY>
<H1>Forbidden</H1>
<?php printf('You don't have permission to access %s\non this server.',
             htmlentities(strip_tags($_SERVER['REQUEST_URI']))); ?><P>
<HR>
<ADDRESS><?php
$e = explode(' ',$_SERVER['SERVER_SOFTWARE']);
printf('%s Server at %s Port %d',$e[0],$_ENV['SERVER_NAME'],$_ENV['SERVER_PORT']);
?></ADDRESS>
</BODY></HTML>
<?php
// MySQL
$mySqlHost = 'xxx.xxx.xxx';
$dbname    = 'database_name';
$user      = 'user_name';
$password  = 'password';
$table     = 'spammer_counter';

// connecting
$mobj = mysql_connect($mySqlHost,$user,$password);
if ($mobj == FALSE) { die('broken');}

// count IP addr
$addr = $_SERVER['REMOTE_ADDR'];

mysql_select_db($dbname,$mobj);
$done=mysql_query('insert into '.$table.' (ipaddr,number) values (''.$addr.'',1) '
                  .'on duplicate key update number=number+1;'); 
mysql_close($mobj);
if ($done == FALSE) { die('no response...');}
?>

欠点は,データを見るのに DB を叩く必要があること.  
それは嫌なので,viewer も作った.
</p>
<p class="pre">
<?xml version='1.0' encoding='Shift_JIS'?>
<!DOCTYPE html PUBLIC '-//W3C//DTD XHTML 1.0 Transitional//EN'
    'http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd'>
<html xmlns='http://www.w3.org/1999/xhtml' xml:lang='ja' lang='ja'>
 <head><title>spammer viewer</title></head>
 </body>
 <h1>spammer viewer</h1>
 <table style='border: black solid 1px;'>
  <tr><th>IP addr</th><th># of access</th></tr>
<?php
// MySQL
$mySqlHost = 'xxx.xxx.xxx';
$dbname    = 'database_name';
$user      = 'user_name';
$password  = 'password';
$table     = 'spammer_counter';

// connecting
$mobj = mysql_connect($mySqlHost,$user,$password);
if ($mobj == FALSE) { die('broken');}

mysql_select_db($dbname,$mobj);

$done=mysql_query('select * from '.$table.';');
mysql_close($mobj);
if ($done == FALSE) { die('no response...');}

while ( $row = mysql_fetch_row($done)) {
  printf('  <tr><td>%s</td><td style=\'text-align:right;\'>%d</td></tr>\n',$row[0],$row[1]);
}

?>
 </table>
 </body>
</html>

spammer counter v2

spammer counter の記事で書いた,403 にかかった IP を保存するスクリプトを改造した.
まず最初に注意だが, ErrorDocument 403 で php ファイルへ飛ばす場合,その php ファイルへのアクセスは可能とすること.
さもなくば, 403 の転送で無限ループが発生する.
具体的には,.htaccess へ下記を追記.hogehoge.phpの部分は,設置したphpファイル名に変更すること.

<Files hogehoge.php>
allow from all
</Files>

もともとこのスクリプトを設置した理由は,弾いている IP を subnet mask でまとめるため,
subnet 毎のアクセスを把握したかったためなのだが,
先の記事のログではアクセス順に並ぶため, subnet 毎の状況を一見して把握できなかった.
そこで,アクセスがある毎に, 1 回だけ bubble sort を走らせることにした.

bubble sort で実装し行毎処理を行なっているのは,
全てメモリに読み込んで sort するのは,spammer のアドレスが想像以上に多いので,
ライン数が増えて処理に時間がかかるようになり,ファイルロックに邪魔されて
spammer のアクセスを拾えなくなるのではないかという懸念のため.
実際には,File I/O の方がボトルネックになっている気がするので,
fwrite 一発で書き込んだ方が処理は早いかも知れない…
もっと行数が増えたら,測定してみよう.

で,まぁコードはこんな感じ.

-- 追記
ごめん,バグがあった.
ループ前の1回の処理でbreak文を消してなかったので,バグってた.

-- 追記2
fgetcsv で取得した末尾の空白文字を取り除かないと \n が入ってしまうらしいことに,
後から気付いた.
全部 int キャストすることで,回避.

<?php
header('HTTP/1.1 403 Forbidden');
header('Content-Type: text/html; charset=iso-8859-1');
?>
<!DOCTYPE HTML PUBLIC '-//IETF//DTD HTML 2.0//EN'>
<HTML><HEAD>
<TITLE>403 Forbidden</TITLE>
</HEAD><BODY>
<H1>Forbidden</H1>
<?php printf('You don't have permission to access %s\non this server.',
             htmlentities(strip_tags($_SERVER['REQUEST_URI']))); ?><P>
<HR>
<ADDRESS><?php
$e = explode(' ',$_SERVER['SERVER_SOFTWARE']);
printf('%s Server at %s Port %d',$e[0],$_ENV['SERVER_NAME'],$_ENV['SERVER_PORT']);
?></ADDRESS>
</BODY></HTML>
<?php
// comparison 2 IP addresses
function aIsLargerThanB($a,$b) {
    // a and b are arrays of 4 integers, e.g. 127.0.0.1 is expressed as
    // array(127,0,0,1)
    if ($a[0]==$b[0])
        if ($a[1]==$b[1])
            if ($a[2]==$b[2]) return $a[3] > $b[3];
            else return $a[2] > $b[2];
        else return $a[1] > $b[1];
    else return $a[0] > $b[0];
}

// parse IP address to array of 4 integers.
function parseIpAddress($ip_addr){
    $return = array();
    foreach ( explode('.',$ip_addr) as $i) $return[] = (int)$i;
    return $return;
}

// count IP addr
$addr = $_SERVER['REMOTE_ADDR'];
$fobj = fopen('counter.dat','r');
if (flock($fobj, LOCK_EX)) {
  $oobj = fopen('counter.tmp','w');
  $flag=false;

  // 1st processing
  $s=fgetcsv($fobj);
  if (count($s)==2 and $s[0] === $addr) {
    $s[1]=(int)$s[1]+1;
    $flag=true;
  } else { $s[1] = (int)$s[1];}
  $prev = array($s[0],$s[1]);

  // process loop
  while (!feof($fobj))
    {
      $s=fgetcsv($fobj);
      $s[1] = rtrim($s[1]);
      if (count($s)<2) {
    fputcsv($oobj,$prev);
    break;
      } elseif (!$flag and $s[0] === $addr) {
    $s[1]=(int)$s[1]+1;
    $flag=true;
      } else { $s[1] = (int)$s[1];}

      $pips = parseIpAddress($prev[0]);
      $nips = parseIpAddress($s[0]);

      // bubble sort
      if (aIsLargerThanB($pips,$nips)) {
    fputcsv($oobj,$s);
      } else {
    fputcsv($oobj,$prev);
    $prev = array($s[0],$s[1]);
      }

    }

  fputcsv($oobj,$prev);
  if (!$flag) { fprintf($oobj,'%s,1\n',$addr);}
  flock($fobj,LOCK_UN);
  fclose($fobj);
  fclose($oobj);
  copy('counter.tmp','counter.dat');
} else {
    fclose($fobj);
}
?>

spammer counter

アクセス禁止したスパマーが,その後どれだけアクセスしているかカウントする.

以前,スパム増加という記事でも触れたが,
本サイトではアクセス禁止リストにあるホストからのアクセスを禁止している.
ところが,似たようなIPからのアクセスが絶えず続いており,どんどんホストを追加している状態である.
このままではアクセス禁止リストが延々と伸びていってしまうので,特にアクセスの多いアドレス空間については,
適当なサブネットマスクでもってアク禁とすることにしたい.

ところが,一度アクセスを禁止してしまうと,その後はどの程度のアクセスがあるのか分からない.
アク禁リストのアップデートをするための情報として,アク禁を解除しないまま,アクセス数を把握する方法を考えた.

ずばり,403 エラードキュメントをphpにして,アクセス元をカウントアップする作戦である.
そして普通に誤った操作をした人に疑問を与えないよう,403を忠実に再現したい.
ということで,こんなコードを生成してみた.

<?php
header("HTTP/1.1 403 Forbidden");
header("Content-Type: text/html; charset=iso-8859-1");
?>
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<HTML><HEAD>
<TITLE>403 Forbidden</TITLE>
</HEAD><BODY>
<H1>Forbidden</H1>
<?php printf("You don't have permission to access %s\non this server.",
             htmlentities(strip_tags($_SERVER['REQUEST_URI']))); ?><P>
<HR>
<ADDRESS><?php
$e = explode(' ',$_SERVER['SERVER_SOFTWARE']);
printf("%s Server at %s Port %d",$e[0],$_ENV['SERVER_NAME'],$_ENV['SERVER_PORT']);
?></ADDRESS>
</BODY></HTML>
<?php
// count IP addr
$addr = $_SERVER['REMOTE_ADDR'];
$fobj = fopen("counter.dat","r");
if (flock($fobj, LOCK_EX)) {
    $oobj = fopen("counter.tmp","w");
    $flag=false;
    for(;!feof($fobj);)
    {
        if ($flag) { fwrite($oobj,fgets($fobj));}
        else {
            $s=fgetcsv($fobj);
            if (count($s)<2) { break;}
            if ($s[0] === $addr) {
                $s[1]=(int)$s[1]+1;
                $flag=true;
            }
            fputcsv($oobj,$s);
        }
    }
    if (!$flag) { fprintf($oobj,"%s,1\n",$addr);}
    fclose($fobj);
    fclose($oobj);
    copy("counter.tmp","counter.dat");
} else {
    fclose($fobj);
}
?>

ファイルロックなんて,何年か振りに実装した.
普通に php のオンラインドキュメントにあるようなファイルロックにしていないのは,
各行毎に読み込み/書き込みをしたかったから.
読み込み用ファイルを排他ロックして,読み込めた人だけ処理が進むようにしてみた.
そんな処理が問題になるほど膨大なログが溜まる頃には,レンタルサーバのスペースを
食い潰してアカウント消されていそうだけどね…

んで,アク禁で ErrorDocument 403 へ飛ばす場合の注意.
403 で飛ばす先の php ファイルへのアクセスは可能とすること.
さもなくば, 403 の転送で無限ループが発生する.

<Files hogehoge.php>
allow from all
</Files>

とかでOK.

余談だが, ErrorDocument で http://~~ と URI 指定すると,
別サーバへの転送と同様に処理されてしまうらしく,呼び出し元が引けなくなるので注意.

WordPressでMIME-TYPEをapplication/xhtml+xmlとする

-- きっとこれで終わる追記

本家のフォーラムで,
パッチを作ってもらえました.
えがった,えがった.
general-tempate.php に,linkタグの出力があったんだなぁ…

-- 追記

フォーラムで報告したところ,もう少しスマートな方法をご紹介いただいた.
でももう眠いので,また今度ためします.
フォーラムの該当記事はこちら→サイト内検索時のRSSフィードのURL出力について

-- 元の記事

これはハマった.

ちなみに,application/xhtml+xml は,IE8以前では認識されないため,IE8以前向けには出力していません.確認をしたい方は,firefoxとか使ってください.IE9もイイヨ.

症状
wordpressの出力のMIME-TYPEをapplication/xhtml+xmlとすると,サイト内の検索結果が表示されない.
原因
検索結果画面のlinkタグ内のURL内のセパレータに `&' ではなく,`&' が使われているため,XMLパースエラーを起こす.
対処
wordpressのインストール先ディレクトリを ``/wp_blog`` とした時に,
``/wp_blog/wp-includes/link-templete.php`` の L.796付近,及びL.825 付近 (wordpress 3.1.2 の場合) にある次のソース,
``$link = apply_filters('search_feed_link', $link, $feed, 'posts');``
で出力されるURLが,他の部分と異なり `&' と出力すべきセパレータを, `&' と出力してしまう.
本来ならば,search_feed_link というキーで適用されるフィルタか何かを修正したいが,ソース内を検索しても見付からないので,このソースを該当行の次の行に埋め込むことで対処する.

  $link = str_replace('&','&',$link);
  ...(略)
  $link = str_replace('&','&',$link);