#!/bin/sh # -*- sh -*- : << =cut =head1 NAME dspam_ - Plugin to monitor various aspects of DSPAM performance =head1 APPLICABLE SYSTEMS Any system running a recent (3.8.0 or higher) DSPAM install. =head1 CONFIGURATION The plugin uses the output of the dspam_stats command, which is usually part of any DSPAM install. You'll need to run this plugin as a user that has enough rights to run dspam_stats and generate data for all users. This means that the plugin needs to be run either as root, or as a user that has read access to dspam.conf, and is added as a Trusted user in dspam.conf. The following environment variables are used by this plugin: dspam_stats - Where to find the dspam_stats binary when it's not in $PATH (default: find anywhere in $PATH). statefile - Where to read/write the statefile that is used to store dspam_stats output (default: $MUNIN_PLUGSTATE/dspam.state). warning - When to trigger a warning (default: 95:). critical - When to trigger a critical (default: 90:). pattern - A pattern that is passed to grep in order to find the DSPAM uids to display. When this variable is set, the value of target (see USAGE) is ignored (default: empty). description - A string describing the set of uids selected by above pattern (default: empty). Warning and critical values can also set on a DSPAM uid basis, use Munins internal format for the DSPAM uid for this notation (see CONFIGURATION EXAMPLES and USAGE for details). =head2 CONFIGURATION EXAMPLES [dspam*] user root env.dspam_stats /opt/dspam/bin/dspam_stats env.statefile /tmp/dspam.state [dspam_accuracy*] env.critical 95: env.warning 96: # raise warning level for username@example.org env.username_example_org_warning 97: # show all accounts from one domain env.pattern @example\.org env.description domain example.org =head1 USAGE Link this plugin to /etc/munin/plugins/ and restart the munin-node. The link should be in the format: dspam__, where: graph - One of: accuracy, processed, absprocessed, relprocessed. target - The uid that DSPAM generates in dspam_stats output, but converted to Munin internal name format. Normally this means that non-alphabetic and non-numeral characters are replaced by an underscore. For example, username@example.org will become username_example_org. A special case is uid ALL, which will draw a graph for a total of all uids, or for a list of all uids (depending on the graph type). NB For advanced uid selection such as 'all users of domain example.org', please see the environment variable 'pattern' under CONFIGURATION. =head1 INTERPRETATION The plugin supports the following graph types: accuracy - Shows the overall accuracy of all users as a percentage. The overall accuracy is the number of correctly classified messages (both ham and spam) in relation to the number of all processed messages. absprocessed - Shows the absolute numbers of messages processed, sorted by the classification that DSPAM uses. The numbers are stacked, making the height of the column display the increase of processed messages over time. relprocessed - Shows the same data as dspam_absprocessed_, but as messages per minute instead of ever-growing absolute values. processed - Shows the same data as dspam_absprocessed_, but as percentage of the total amount of processed messages, making it clear to see how the amounts of classified messages are divided. =head1 AUTHOR Copyright 2010 Tom Hendrikx =head1 LICENSE GPLv2 This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; version 2 dated June, 1991. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. =head1 BUGS None known. Please report to author when you think you found something. =head2 TODO LIST Currently developed and tested with bash/dash on linux. More testing might be needed with other shells and OSes. =head1 VERSION $Id: dspam_ 72 2010-09-15 22:09:15Z tomhendr $ =head1 MAGIC MARKERS #%# family=auto #%# capabilities=autoconf suggest =cut # defaults for configurable settings : ${dspam_stats:=$(which dspam_stats 2> /dev/null)} : ${statefile:=${MUNIN_PLUGSTATE}/dspam.state} : ${statefile_max_age:=180} # 3 minutes : ${warning:=95:} : ${critical:=90:} # include munin plugin helper . $MUNIN_LIBDIR/plugins/plugin.sh ####################################### # Some generic file locking functions # ####################################### # # file_is_usable $file $max_age # Check if file is OK for usage: existing, readable and not too old # file_is_usable() { local file=$1 local max_age=$2 local lock=$file.lock [ ! -f $file ] && debug statefile $file does not exist && return 66 # EX_NOINPUT [ ! -r $file ] && debug statefile $file is not readable && return 65 # EX_DATAERR local mtime=$(stat --format %Y $file) local file_age=$(( $(date +%s) -mtime )) [ $file_age -gt $max_age ] && debug file $file is too old: $file_age seconds && return 65 # EX_DATAERR debug file $file is ok, $file_age seconds old return 0 # EX_OK } # # file_get_lock $file # Obtain a lock for the named file # file_get_lock() { local file=$1 local lock=$file.lock while file_is_locked $file; do sleep 1 done echo $$ > $lock [ $? -gt 0 ] && debug failed to create lockfile $lock && return 73 # EX_CANTCREAT debug created lock for $file return 0 # EX_OK } # # file_remove_lock $file # Remove a set lockfile for the named file # file_remove_lock() { local file=$1 local lock=$file.lock rm -f $lock [ $? -gt 0 ] && debug failed to remove lockfile $lock && return 69 # EX_UNAVAILABLE debug removed lock for file $file return 0 # EX_OK } # # file_is_locked $file # Check if a file is locked # file_is_locked() { local file=$1 local lock=$1.lock if [ -f "$lock" ];then debug file $file is locked local pid=$(cat "$lock") if ps h -p "$pid" -o comm=|grep -q dspam_; then return 0 # EX_OK else debug lock for file $file is no longer valid file_remove_lock $file fi fi debug file $file is not locked return 69 # EX_UNAVAILABLE } #################################### # DSPAM output processing function # #################################### # # update_statefile # Read the output of dspam_stats, convert it and write usable data to the statefile # update_statefile() { file_is_usable $statefile $statefile_max_age && return $? # return when all OK ! file_get_lock $statefile && return $? # return when locking failed local tmpfile=$(mktemp) debug created tmpfile $tmpfile debug starting $dspam_stats -t -S local t_start=$(date +%s) $dspam_stats -t -S | while IFS=' :' read a b c d e f g h i j k l m x; do # example of output format (3.9.1 rc1) for each user: #username@example.org # TP: 0 TN: 2147 FP: 0 FN: 53 SC: 0 NC: 0 # SHR: 0.00% HSR: 0.00% OCA: 97.59% # or for short user names: #vmail TP:80312082 TN:198342928 FP: 82941 FN: 57326 SC: 0 NC: 3498 # SHR: 99.56% HSR: 0.00% OCA: 99.69% case $a in TP) # the 2nd line local tp=$b tn=$d fp=$f fn=$h sc=$j nc=$l ;; SHR) # the 3rd line local shr=$(echo $b | sed 's/%$//g') local hsr=$(echo $d | sed 's/%$//g') local oca=$(echo $f | sed 's/%$//g') # we're done, write data from current user to the statefile local clean_user=$(clean_fieldname $uid) local state="$uid $clean_user $tp $tn $fp $fn $sc $nc $shr $hsr $oca" echo $state >> $tmpfile [ $? -gt 0 ] && debug failed to write data for $uid to tmpfile && return 73 # EX_CANTCREAT debug wrote data for $uid to tmpfile: $state ;; *) # the 1st line local uid=$a # data from 2nd line is also here [ "$b" = "TP" ] && local tp=$c tn=$e fp=$g fn=$i sc=$k nc=$m ;; esac done local t_end=$(date +%s) debug dspam_stats finished, runtime $((t_end - t_start)) seconds mv $tmpfile $statefile [ $? -gt 0 ] && debug failed to move tmpfile to $statefile && return 73 # EX_CANTCREAT debug moved tmpfile to $statefile file_remove_lock $statefile return 0 # EX_OK } # # abs2perc # Outputs a percentage calculated from its two arguments # abs2perc() { # division by zero protection: debug output is merely informal and harmless ;) [ $1 -eq 0 ] && echo 0 && debug abs2perc prevented possible division-by-zero on first argument && return 0 # EX_OK [ $2 -eq 0 ] && echo 0 && debug abs2perc prevented possible division-by-zero on second argument && return 0 # EX_OK echo $1 $2 | awk '{ print $1 * 100 / $2 }' } # # debug $output # Prints debugging output when munin-run is called with --pidebug argument (i.e. when MUNIN_DEBUG is set) # debug() { if [ -n "$MUNIN_DEBUG" ]; then echo "# DEBUG: $@" fi } ######################################## # Functions that generate munin output # ######################################## # # print_autoconf # Output for 'munin-node-configure' autoconf functionality # print_autoconf() { if [ -z "$dspam_stats" ]; then echo "no (no dspam_stats binary found)" elif [ ! -x $dspam_stats ]; then echo "no ($dspam_stats found but not executable)" else echo yes fi } # # print_suggest # Output for 'munin-node-configure --suggest' # print_suggest() { echo accuracy_ALL echo processed_ALL echo absprocessed_ALL echo relprocessed_ALL } # # print_config # Output for 'munin-run config' command. # print_config() { debug printing config for graph: $graph while file_is_locked $statefile; do debug statefile is locked, waiting sleep 1 done case $graph in accuracy) if [ -n "$pattern" ]; then debug env.pattern was set, so use it: $pattern local uid=$description local uid_count=$(grep $pattern $statefile | wc -l) debug uid_count retrieved from statefile: $uid_count elif [ "$target" = "ALL" ]; then local pattern="-v TOTAL" debug target=ALL: need pattern for all users but not TOTAL: $pattern local uid="all users" local uid_count=$(grep $pattern $statefile | wc -l) debug uid_count retrieved from statefile: $uid_count else local pattern="\b$target\b" debug target=$target: need pattern for a single user: $pattern local uid=$(grep $pattern $statefile | cut -d' ' -f1) debug retrieved uid value from statefile: $uid local uid_count=1 fi echo "graph_title Accuracy for $uid" echo graph_category spamfilter echo graph_args --base 1000 --upper-limit 100 --rigid echo graph_vlabel Accuracy in % echo "graph_info This graph shows the current DSPAM Overall Accuracy for $uid ($uid_count uids). Overall Accuracy is the percentage of messages that is classified correctly as either ham or spam." debug starting grep for user data in statefile local t_start=$(date +%s) grep $pattern $statefile | while read uid clean_user x; do echo $clean_user.label $uid print_warning $clean_user print_critical $clean_user done local t_end=$(date +%s) debug grep finished, runtime $((t_end - t_start)) seconds ;; processed) if [ -n "$pattern" ]; then debug env.pattern was set, so use it: $pattern local uid=$description local uid_count=$(grep $pattern $statefile | wc -l) debug uid_count retrieved from statefile: $uid_count elif [ "$target" = "ALL" ]; then local pattern="-v TOTAL" debug target=ALL: need pattern for all users but not TOTAL: $pattern local uid="all users" local uid_count=$(grep $pattern $statefile | wc -l) debug uid_count retrieved from statefile: $uid_count else local pattern="\b$target\b" debug target=$target: need pattern for a single user: $pattern local uid=$(grep $pattern $statefile | cut -d' ' -f1) debug retrieved uid value from statefile: $uid local uid_count=1 fi echo "graph_title Processed messages for $uid (%)" echo graph_category spamfilter echo graph_args --base 1000 --upper-limit 100 --rigid echo graph_vlabel Messages in % echo "graph_info This graph shows the messages that DSPAM processed for $uid ($uid_count uids) in percentages of all processed messages. Messages are divided in the following categories: true positives/negatives, false positives/negatives, and corpusfed ham/spam." echo tp.label True positives echo tp.info Spam messages correctly classified as spam. echo tp.draw AREASTACK echo tn.label True negatives echo tn.info Ham messages correctly classified as ham. echo tn.draw AREASTACK echo fp.label False positives echo fp.info Ham messages incorrectly classified as spam, but corrected by the user. echo fp.draw AREASTACK echo fn.label False negatives echo fn.info Spam messages incorrectly classified as ham, but corrected by the user. echo fn.draw AREASTACK echo sc.label Corpusfed spam echo sc.info Spam messages from a collected corpus for training purposes. echo sc.draw AREASTACK echo nc.label Corpusfed ham echo nc.info Ham messages from a collected corpus for training purposes. echo nc.draw AREASTACK ;; *processed) if [ -n "$pattern" ]; then debug env.pattern was set, so use it: $pattern local uid=$description local uid_count=$(grep $pattern $statefile | wc -l) debug uid_count retrieved from statefile: $uid_count elif [ "$target" = "ALL" ]; then local pattern="-v TOTAL" debug target=ALL: need pattern for all users but not TOTAL: $pattern local uid="all users" local uid_count=$(grep $pattern $statefile | wc -l) debug uid_count retrieved from statefile: $uid_count else local pattern="\b$target\b" debug target=$target: need pattern for a single user: $pattern local uid=$(grep $pattern $statefile | cut -d' ' -f1) debug retrieved uid value from statefile: $uid local uid_count=1 fi echo "graph_title Processed messages for $uid" echo graph_category spamfilter echo graph_args --base 1000 [ "$graph" = absprocessed ] && echo graph_vlabel Messages [ "$graph" = relprocessed ] && echo graph_vlabel Messages / minute [ "$graph" = relprocessed ] && echo graph_period minute echo graph_total Total echo "graph_info This graph shows the messages that DSPAM processed for $uid ($uid_count uids). Messages are divided in the following categories: true positives/negatives, false positives/negatives, and corpusfed ham/spam." echo tp.label True positives echo tp.info Spam messages correctly classified as spam. [ "$graph" = relprocessed ] && echo tp.type DERIVE echo tp.draw AREASTACK echo tn.label True negatives echo tn.info Ham messages correctly classified as ham. [ "$graph" = relprocessed ] && echo tn.type DERIVE echo tn.draw AREASTACK echo fp.label False positives echo fp.info Ham messages incorrectly classified as spam, but corrected by the user. [ "$graph" = relprocessed ] && echo fp.type DERIVE echo fp.draw AREASTACK echo fn.label False negatives echo fn.info Spam messages incorrectly classified as ham, but corrected by the user. [ "$graph" = relprocessed ] && echo fn.type DERIVE echo fn.draw AREASTACK echo sc.label Corpusfed spam echo sc.info Spam messages from a collected corpus for training purposes. [ "$graph" = relprocessed ] && echo sc.type DERIVE echo sc.draw AREASTACK echo nc.label Corpusfed ham echo nc.info Ham messages from a collected corpus for training purposes. [ "$graph" = relprocessed ] && echo nc.type DERIVE echo nc.draw AREASTACK ;; *) debug no config available for graph: $graph, exiting with error exit 78 # EX_CONFIG ;; esac debug finished with printing config for graph: $graph } # # print_fetch # Output for 'munin-run fetch' command: the actual data to graph. # print_fetch() { debug printing fetch for graph: $graph while file_is_locked $statefile; do debug statefile is locked, waiting sleep 1 done case $graph in accuracy) if [ -n "$pattern" ]; then debug env.pattern was set, so use it: $pattern continue elif [ $target = "ALL" ]; then local pattern="-v TOTAL" debug target=ALL: need pattern for all users, but not for TOTAL: $pattern else local pattern="\b$target\b" debug target=$target: need pattern for a single user: $pattern fi debug starting grep for user data in statefile local t_start=$(date +%s) grep $pattern $statefile | while read x clean_user x x x x x x x x oca x; do echo $clean_user.value $oca done local t_end=$(date +%s) debug grep finished, runtime $((t_end - t_start)) seconds ;; processed) if [ -n "$pattern" ]; then debug env.pattern was set, so use it: $pattern continue elif [ $target = "ALL" ]; then local pattern="TOTAL" debug target=ALL: need pattern for TOTAL of all users: $pattern else local pattern="\b$target\b" debug target=$target: need pattern for a single user: $pattern fi local tmpfile=$(mktemp) || debug failed to create tmpfile && debug tmpfile created at: $tmpfile debug starting grep for user data in statefile, sending to tmpfile local t_start=$(date +%s) grep $pattern $statefile > $tmpfile local t_end=$(date +%s) debug grep finished, runtime $((t_end - t_start)) seconds debug starting loop over data in tmpfile while read user x tp tn fp fn sc nc x; do debug read data for user $user: $tp $tn $fp $fn $sc $nc all_tp=$((all_tp + tp)) all_tn=$((all_tn + tn)) all_fp=$((all_fp + fp)) all_fn=$((all_fn + fn)) all_sc=$((all_sc + sc)) all_nc=$((all_nc + nc)) debug calculated new totals: $all_tp $all_tn $all_fp $all_fn $all_sc $all_nc done < $tmpfile debug finished data loop rm -f $tmpfile || debug failed to remove tmpfile && debug removed tmpfile local total=$((all_tp + all_tn + all_fp + all_fn + all_sc + all_nc)) debug calculated total of all messages: $total echo tp.value $(abs2perc $all_tp $total) echo tn.value $(abs2perc $all_tn $total) echo fp.value $(abs2perc $all_fp $total) echo fn.value $(abs2perc $all_fn $total) echo sc.value $(abs2perc $all_sc $total) echo nc.value $(abs2perc $all_nc $total) ;; *processed) if [ -n "$pattern" ]; then debug env.pattern was set, so use it: $pattern continue elif [ $target = "ALL" ]; then local pattern="TOTAL" debug target=ALL: need pattern for TOTAL of all users: $pattern else local pattern="\b$target\b" debug target=$target: need pattern for a single user: $pattern fi local tmpfile=$(mktemp) || debug failed to create tmpfile && debug tmpfile created at: $tmpfile debug starting grep for user data in statefile, sending to tmpfile local t_start=$(date +%s) grep $pattern $statefile > $tmpfile local t_end=$(date +%s) debug grep finished, runtime $((t_end - t_start)) seconds debug starting loop over data in tmpfile while read user x tp tn fp fn sc nc x; do debug read data for user $user: $tp $tn $fp $fn $sc $nc all_tp=$((all_tp + tp)) all_tn=$((all_tn + tn)) all_fp=$((all_fp + fp)) all_fn=$((all_fn + fn)) all_sc=$((all_sc + sc)) all_nc=$((all_nc + nc)) debug calculated new totals: $all_tp $all_tn $all_fp $all_fn $all_sc $all_nc done < $tmpfile debug finished data loop rm -f $tmpfile || debug failed to remove tmpfile && debug removed tmpfile echo tp.value $all_tp echo tn.value $all_tn echo fp.value $all_fp echo fn.value $all_fn echo sc.value $all_sc echo nc.value $all_nc ;; *) debug no fetch available for graph: $graph, exiting with error exit 78 # EX_CONFIG ;; esac debug finished printing fetch for graph: $graph } ##################### # Main process loop # ##################### # show env settings debug dspam_ plugin started, pid=$$ debug settings: debug - dspam_stats is set to: $dspam_stats debug - statefile is set to: $statefile debug - statefile_max_age is set to: $statefile_max_age debug - warning is set to: $warning debug - critical is set to: $critical debug - pattern is set to: $pattern debug - description is set to: $description command=$1 [ -n "$command" ] || command="fetch" debug - command is set to: $command graph=$(basename $0 | cut -d'_' -f2) debug - graph is set to: $graph target=$(basename $0 | cut -d'_' -f3-) [ -n "$target" ] || target="ALL" debug - target is set to: $target debug settings completed, starting process case $command in autoconf) print_autoconf ;; suggest) print_suggest ;; config) update_statefile print_config ;; fetch) update_statefile print_fetch ;; esac debug exiting exit 0 # EX_OK