Apache Camel と Groovy Script で Spamhaus プロジェクトのリストに登録されているかをチェックする

 最近、仕事で、OSGi に関係した技術を調査していたのですが、その流れの中で Apache Camel を知りました。取り組んでみると、かなり面白いので、仕事とは別に日常的に利用したいと考えるようになりました。そこで目を付けたのが、Groovy です。この組み合わせだと、かなりお手軽に Camel を扱えるのではないかと思ったのでした。

 しかし、実際にやってみると、思いもしない所で時間を使いました。それは、Camel の Route を一回だけ実行し、速やかにスクリプトを終了するという部分です。そんなわけで、他の方にも何か役立つかもしれないので、記事に残すことにしました。

 今回、この記事用に書いたスクリプトは、Spamhaus の Spam リストに、任意の IP アドレスが登録されていないかをチェックするものです。チェックをして、登録されていたらメールで通知してくれます。
 処理は、Spamhaus の IP Address Lookup Tool を呼び出し、それにより得られる HTML を解析して、SBLPBL, そして XBL に登録されているかをチェックするようにしてみました。そのため、当然、応答される HTML の内容が変わると、使えなくなりますのでご注意ください。

 IP Address Lookup Tool を呼び出して得られる HTML は、まず .unmarshal().tidyMarkup() の処理で XML データにしています。そして、その XML データを .split(…)を 使って SBL と PBL 、XBL の状態を示す3つの文字列に分割し、それらを再度 .aggregate(…) を使って連結することでひとつの文字列として組み立て直しています。そしてさらに、その文字列の中に ”is listed in the” が含まれるかどうかチェックすることで Spam リストに登録されているかを判断し、もし登録されていれば、その文字列を本文とするメールを送信しています。

 届くメールは、次のような内容になります。
spamhaus_check_mail
 このメールの内容は、PBL に登録されていたことを示しています。

 次に示すのが、スクリプトの全体です。

@Grab('org.apache.camel:camel-core:2.15.0')
@Grab('org.apache.camel:camel-http:2.15.0')
@Grab('org.apache.camel:camel-tagsoup:2.15.0')
@Grab('org.apache.camel:camel-mail:2.15.0')
@Grab('org.slf4j:slf4j-simple:1.7.10')

import org.apache.camel.*
import org.apache.camel.impl.*
import org.apache.camel.builder.*
import org.apache.camel.builder.xml.*
import org.apache.camel.processor.aggregate.*
import static java.util.Calendar.getInstance as now

def camelContext = new DefaultCamelContext()
def propCompo = camelContext.getComponent("properties")
propCompo.setInitialProperties(new java.util.Properties())
propCompo.getInitialProperties().put("subject", "SPAMHAUSのリストに登録されてしまいました")
propCompo.getInitialProperties().put("chkIpAddress", "") // ex. 119.238.140.134
propCompo.getInitialProperties().put("smtpUri", "") // ex. mail.local.wingnest.com
propCompo.getInitialProperties().put("fromEmail", "") // ex. root@wingnest.com
propCompo.getInitialProperties().put("toEmail", "") // ex. gougi@wingnest.com

class StopProcessor implements Processor {
    def stop
    int status
    StopProcessor(int status) {
        this.status = status
    }
    void process(Exchange exchange) throws Exception {
        if (stop == null) {
            stop = new Thread() {
                public void run() {
                    System.exit(status)
                }
            }
        }
        stop.start()
    }
}

camelContext.addRoutes(new RouteBuilder() {
    def void configure() {
        onException(Exception.class).handled(true).process(new StopProcessor(-1))
        from("timer:?fixedRate=true&repeatCount=1")
                .setHeader("user-agent").simple("Mozilla/5.0", String.class)
                .to("http://www.spamhaus.org/query/ip/{{chkIpAddress}}")
                .unmarshal().tidyMarkup()
                .split(new XPathBuilder("//b/font/text()[contains(., 'in the')]"))
                .aggregate(header("breadcrumbId"), { Exchange oldExchange, Exchange newExchange ->
                        if (oldExchange == null)
                            return newExchange
                        def oldBody = oldExchange.getIn().getBody(String.class)
                        def newBody = newExchange.getIn().getBody(String.class)
                        oldExchange.getIn().setBody(oldBody + "\n" + newBody)
                        return oldExchange
                    } as AggregationStrategy).completionSize(3)
                .choice()
                    .when(body().contains("is listed in the"))
                        .setHeader("Subject").simple("{{subject}}", String.class)
                        .setHeader("Content-Type").simple("text/plain", String.class)
                        .setHeader("Date").simple(now().time.toString(), String.class)
                        .setHeader("To").simple("{{toEmail}}", String.class)
                        .setHeader("From").simple("{{fromEmail}}", String.class)
                        .to("smtp:{{smtpUri}}")
                        .process(new StopProcessor(0))
                .otherwise().process(new StopProcessor(0))
    }
})

camelContext.start()
synchronized(this){ this.wait() }

 このスクリプトの中のポイント、つまりこの記事で書き残したいと思ったところは、CamelContext を終了するために StopProcessor を使っている点です。これよりもいい方法があるかもしれませんが、今のところこのやり方以外を見つけることはできていません。他にあれば、是非、教えてください!

 このスクリプトは、次のようにして呼び出すことができます。

groovy -Dfile.encoding=UTF-8 -DchkIpAddress=119.238.140.134 \
       -DfromEmail=gougi@wingnest.com -DtoEmail=gougi@wingnest.com \
       -DsmtpUri=mail.local.wingnest.com CheckSpamhaus.groovy

 file.encodeing は、環境によっては不要と思います。chkIpAddress は、チェックしたいグローバル IP アドレス、fromEmail は Spam 登録されていた場合に通知するメールの送信先メールアドレス、toEmail はその時の送信元メールアドレス、smtpUri はMAIL コンポーネントの要求形式での uri を指定します。

 たとえば、DDNS を使っている環境であれば、次のような Shell スクリプトを書き、それを cron に登録して利用することができるでしょう。

#!/bin/sh

PATH=/sbin:/usr/sbin:/bin:/usr/bin:/usr/local/groovy/bin
export PATH

ip=`ifconfig ppp0 2> /dev/null | grep inet | awk '{print $2}'`
groovy -Dfile.encoding=UTF-8 -DchkIpAddress=$ip \
       -DfromEmail=gougi@wingnest.com -DtoEmail=gougi@wingnest.com \
       -DsmtpUri=mail.local.wingnest.com /usr/local/cronscripts/CheckSpamhaus.groovy

追記(2015/04/12): Directコンポーネントを使う方法

 コメント欄で、しうへい@野犬さんから、Directコンポーネントを使う方法を教わりました。これ以外に良い方法はないだろうと自己満足していたのですが、早計でした…。f(^^;

 しうへい@野犬さんからのアドバイスを元に修正したコードは次の通りです。

@Grab('org.apache.camel:camel-core:2.15.0')
@Grab('org.apache.camel:camel-http:2.15.0')
@Grab('org.apache.camel:camel-tagsoup:2.15.0')
@Grab('org.apache.camel:camel-mail:2.15.0')
@Grab('org.slf4j:slf4j-simple:1.7.10')

import org.apache.camel.*
import org.apache.camel.impl.*
import org.apache.camel.builder.*
import org.apache.camel.builder.xml.*
import org.apache.camel.processor.aggregate.*
import static java.util.Calendar.getInstance as now

def camelContext = new DefaultCamelContext()
def propCompo = camelContext.getComponent("properties")
propCompo.setInitialProperties(new java.util.Properties())
propCompo.getInitialProperties().put("subject", "SPAMHAUSのリストに登録されてしまいました")
propCompo.getInitialProperties().put("chkIpAddress", "119.238.140.134") // ex. 119.238.140.134
propCompo.getInitialProperties().put("smtpUri", "mail.local.wingnest.com") // ex. mail.local.wingnest.com
propCompo.getInitialProperties().put("fromEmail", "root@wingnest.com") // ex. root@wingnest.com
propCompo.getInitialProperties().put("toEmail", "gougi@wingnest.com") // ex. gougi@wingnest.com

camelContext.addRoutes(new RouteBuilder() {
    def void configure() {
           from("direct:once")
                .setHeader("user-agent").simple("Mozilla/5.0", String.class)
                .to("http://www.spamhaus.org/query/ip/{{chkIpAddress}}")
                .unmarshal().tidyMarkup()
                .split(new XPathBuilder("//b/font/text()[contains(., 'in the')]"))
                .aggregate(header("breadcrumbId"), { Exchange oldExchange, Exchange newExchange ->
                        if (oldExchange == null)
                            return newExchange
                        def oldBody = oldExchange.getIn().getBody(String.class)
                        def newBody = newExchange.getIn().getBody(String.class)
                        oldExchange.getIn().setBody(oldBody + "\n" + newBody)
                        return oldExchange
                    } as AggregationStrategy).completionSize(3)
                .choice()
                    .when(body().contains("is listed in the"))
                        .setHeader("Subject").simple("{{subject}}", String.class)
                        .setHeader("Content-Type").simple("text/plain", String.class)
                        .setHeader("Date").simple(now().time.toString(), String.class)
                        .setHeader("To").simple("{{toEmail}}", String.class)
                        .setHeader("From").simple("{{fromEmail}}", String.class)
                        .to("smtp:{{smtpUri}}")
    }
})

camelContext.start()
camelContext.createProducerTemplate().send("direct:once", new DefaultExchange(camelContext))
camelContext.stop()

StopProcessor クラスを使わなくていい分、シンプルになりました。

追記(2015/04/12):Directコンポーネントを使う方法(その2) ヘルパークラス

 「追記:Directコンポーネントを使う方法」の方法をより簡単に利用するためのヘルパークラスを書きました。
 ソースは、https://github.com/sgougi/groovy-camel-helper においています。

 これを使うと次のように記述できます。

@Grab('org.apache.camel:camel-core:2.15.0')
@Grab('org.apache.camel:camel-http:2.15.0')
@Grab('org.apache.camel:camel-tagsoup:2.15.0')
@Grab('org.apache.camel:camel-mail:2.15.0')
@Grab('org.slf4j:slf4j-simple:1.7.10')

@GrabResolver(name=':groovy-camel-helper', root='http://www.wingnest.com/mvn-repo/')
@Grab('com.wingnest.groovy:groovy-camel-helper:1.0-M1')

import org.apache.camel.*
import org.apache.camel.impl.*
import org.apache.camel.builder.*
import org.apache.camel.builder.xml.*
import org.apache.camel.processor.aggregate.*
import static java.util.Calendar.getInstance as now

import static com.wingnest.groovy.camel.CamelHelper.getDirectOnceUri as once
import static com.wingnest.groovy.camel.CamelHelper.execCamelContext

def camelContext = new DefaultCamelContext()
def propCompo = camelContext.getComponent("properties")
propCompo.setInitialProperties(new java.util.Properties())
propCompo.getInitialProperties().put("subject", "SPAMHAUSのリストに登録されてしまいました")
propCompo.getInitialProperties().put("chkIpAddress", "119.238.140.134") // ex. 119.238.140.134
propCompo.getInitialProperties().put("smtpUri", "mail.local.wingnest.com") // ex. mail.local.wingnest.com
propCompo.getInitialProperties().put("fromEmail", "root@wingnest.com") // ex. root@wingnest.com
propCompo.getInitialProperties().put("toEmail", "gougi@wingnest.com") // ex. gougi@wingnest.com

execCamelContext(camelContext, new RouteBuilder() {
    def void configure() {
           from(once())
                .setHeader("user-agent").simple("Mozilla/5.0", String.class)
                .to("http://www.spamhaus.org/query/ip/{{chkIpAddress}}")
                .unmarshal().tidyMarkup()
                .split(new XPathBuilder("//b/font/text()[contains(., 'in the')]"))
                .aggregate(header("breadcrumbId"), { Exchange oldExchange, Exchange newExchange ->
                    if (oldExchange == null)
                        return newExchange
                    def oldBody = oldExchange.getIn().getBody(String.class)
                    def newBody = newExchange.getIn().getBody(String.class)
                    oldExchange.getIn().setBody(oldBody + "\n" + newBody)
                    return oldExchange
                } as AggregationStrategy).completionSize(3)
                .choice()
                    .when(body().contains("is listed in the"))
                        .setHeader("Subject").simple("{{subject}}", String.class)
                        .setHeader("Content-Type").simple("text/plain", String.class)
                        .setHeader("Date").simple(now().time.toString(), String.class)
                        .setHeader("To").simple("{{toEmail}}", String.class)
                        .setHeader("From").simple("{{fromEmail}}", String.class)
                .to("smtp:{{smtpUri}}")
    }
})

 もう少し便利にならないかなぁ…。RouteBuilder を書きたくないなぁ。