#!/usr/bin/perl
# -*- perl -*-
# $Header: /home/johnl/hack/dmarc/RCS/rddmarc,v 1.5 2013/05/12 17:08:20 johnl Exp johnl $
#
# Script to read DMARC aggregate reports and put summary info
# into a database

# Options:
# -d print debug info
# -x read XML files rather than mail messages
# -r replace existing report rather than failing
# -u database user
# -p database password
# -n database name

# Copyright 2012-2013, Taughannock Networks. All rights reserved.

# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions
# are met:

# Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.

# Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.

# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
# HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS
# OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
# AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY
# WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.

use strict;
use Getopt::Std;
use MIME::Parser;
use MIME::Words qw(:all);
use XML::Simple;
use DBI;
use Socket qw{:addrinfo inet_ntop inet_pton AF_INET6 AF_INET};
use PerlIO::gzip;

use vars qw{$opt_d $opt_r $opt_x $opt_u $opt_p $opt_n};

getopts('drxu:p:n:');
if (!defined($opt_u)) {
    $opt_u = "opendmarc";
}
if (!defined($opt_p)) {
    $opt_p = "opendmarc";
}
if (!defined($opt_n)) {
    $opt_n = "opendmarc";
}

my $dbh = DBI->connect("DBI:mysql:database=$opt_n", $opt_u, $opt_p)
	    or die "Cannot connect to database\n";

foreach my $i (@ARGV) {
    my ($zip, $ent, $isgzip);

    print "parsing $i\n";

    if($opt_x) {
	open(XML, $i) or die "Cannot open XML file $i";
    } else {
	my $parser = new MIME::Parser;
	$parser->output_dir("/tmp");

	$ent = $parser->parse_open($i);
    
	my $body = $ent->bodyhandle;
	$zip = $body;
	my $mtype = $ent->mime_type;
	my $subj = decode_mimewords($ent->get('subject'));
	print " $subj";
	# if multipart/whatever, look through the parts to find a ZIP
	if(lc $mtype =~ "multipart/") {
	    print "Look through $mtype\n";
	    $zip = undef;
	    my $npart = $ent->parts;
	    for my $n (0..($npart-1)) {
		my $part = $ent->parts($n);
		if(lc $part->mime_type eq "application/gzip") {
		    $zip = $part->bodyhandle;
		    $isgzip = 1;
		    last;
		} elsif(lc $part->mime_type eq "application/zip"
		   or lc $part->mime_type eq "application/x-zip-compressed"
		   or lc $part->mime_type eq "application/octet-stream") {
		    $zip = $part->bodyhandle;
		    last;
		} else {
		    $part->bodyhandle->purge; # not useful
		}
	    }
	    die "no zip" unless $zip;
	} elsif(lc $mtype ne "application/zip") {
	    print "don't understand $mtype\n";
	    next;
	}
	if(defined($zip->path)) {
	    print "body is in " . $zip->path . "\n" if $opt_d;
	} else {
	    print "body is nowhere\n";
	    next;
	}
	if($isgzip) {
	    open(XML, "<:gzip", $zip->path)
		or die "cannot ungzip $zip->path";
	} else {
	    open(XML,"unzip -p " . $zip->path . " |")
		or die "cannot unzip $zip->path";
	}
    }
    my $xml = "";
    $xml .= $_ while <XML>;
    close XML;
    $ent->purge if $ent;
    $zip->purge if $zip;
    
    my $xs = XML::Simple->new();
    
    print "XML is ======\n$xml\n=====\n" if $opt_d;

    my $ref = $xs->XMLin($xml);
    my %xml = %{$ref};
    #print join "\n",keys %xml;
    #print "\n";
    my $from = $xml{'report_metadata'}->{'date_range'}->{'begin'};
    my $to = $xml{'report_metadata'}->{'date_range'}->{'end'};
    my $org = $xml{'report_metadata'}->{'org_name'};
    my $id = $xml{'report_metadata'}->{'report_id'};
    my $domain =  $xml{'policy_published'}->{'domain'};
    print "report $org ($id) $from to $to for $domain\n" if $opt_d;
    # see if already stored
    my ($xorg, $xid, $serial) = $dbh->selectrow_array(qq{SELECT org,reportid,serial FROM report WHERE reportid=?}, undef, $id);
    if($xorg) {
	if($opt_r) {
	    print "Replacing $xorg $xid\n";
	    $dbh->do(qq{DELETE from rptrecord WHERE serial=?}, undef, $serial)
		    or die "cannot delete old records" . $dbh->errstr;
	} else {
	    print "Already have $xorg $xid, skipped\n";
	    next;
	}
    }

    my $sql = qq{INSERT INTO report(serial,mindate,maxdate,domain,org,reportid)
		VALUES(NULL,FROM_UNIXTIME(?),FROM_UNIXTIME(?),?,?,?)};
    $sql = qq{REPLACE INTO report(serial,mindate,maxdate,domain,org,reportid)
		VALUES('$serial',FROM_UNIXTIME(?),FROM_UNIXTIME(?),?,?,?)} if $xorg;

    $dbh->do($sql, undef, $from, $to, $domain, $org, $id)
	    or die "cannot make report" . $dbh->errstr;
    $serial = $dbh->{'mysql_insertid'} ||  $dbh->{'insertid'} unless $xorg;
    print " serial $serial ";
    my $record = $xml{'record'};
    sub dorow($$) {
	my ($serial,$recp) = @_;
	my %r = %$recp;

	my $ip = $r{'row'}->{'source_ip'};
	my $count = $r{'row'}->{'count'};
	my $disp = $r{'row'}->{'policy_evaluated'}->{'disposition'};
	print "\nip $ip, count $count, disp $disp" if $opt_d;
	my ($dkim, $dkimresult, $spf, $spfresult, $reason);
	my $rp = $r{'auth_results'}->{'dkim'};
	printf " rp $rp\n" if $opt_d;
	if(ref $rp eq "HASH") {
	    $dkim = $rp->{'domain'};
	    $dkim = undef if ref $dkim eq "HASH";
	    $dkimresult = $rp->{'result'};
	} else { # array
	# glom sigs together, report first result
	    $dkim = join '/',map { my $d = $_->{'domain'}; ref $d eq "HASH"?"": $d } @$rp;
	    $dkimresult = $rp->[0]->{'result'};
	}
	$rp = $r{'auth_results'}->{'spf'};
	if(ref $rp eq "HASH") {
	    $spf = $rp->{'domain'};
	    $spfresult = $rp->{'result'};
	} else { # array
	# glom domains together, report first result
	    $spf = join '/',map { my $d = $_->{'domain'}; ref $d eq "HASH"? "": $d } @$rp;
	    $spfresult = $rp->[0]->{'result'};
	}

	$rp = $r{'row'}->{'policy_evaluated'}->{'reason'};
	if(ref $rp eq "HASH") {
	    $reason = $rp->{'type'};
	} else {
	    $reason = join '/',map { $_->{'type'} } @$rp;
	}
	#print "ip=$ip, count=$count, disp=$disp, r=$reason,";
	#print "dkim=$dkim/$dkimresult, spf=$spf/$spfresult\n";
	# figure out if it's IPv4 or IPv6
	my ($nip, $iptype, $ipval);
	if($nip = inet_pton(AF_INET, $ip)) {
	    $ipval = unpack "N", $nip;
	    $iptype = "ip";
	} elsif($nip = inet_pton(AF_INET6, $ip)) {
	    $ipval = "X'" . unpack("H*",$nip) . "'";
	    $iptype = "ip6";
	} else {
	    print "??? mystery ip $ip\n";
	    next;
	}
	print "$iptype = $ipval\n" if $opt_d;
	$dbh->do(qq{INSERT INTO rptrecord(serial,$iptype,rcount,disposition,reason,dkimdomain,dkimresult,spfdomain,spfresult)
		  VALUES(?,$ipval,?,?,?,?,?,?,?)},undef, $serial,$count,$disp,$reason,$dkim,$dkimresult,$spf,$spfresult)
		or die "cannot insert record " . $dbh->{'mysql_error'};
    } # dorow

    if(ref $record eq "HASH") {
	print "single record\n";
	dorow($serial,$record);
    } elsif(ref $record eq "ARRAY") {
	print "multi record\n";
	foreach my $row (@$record) {
	    dorow($serial,$row);
	}
    } else {
	print "mystery type " . ref($record) . "\n";
    }
}

