#!/usr/local/bin/python2.5
#
#  Check SPF results and provide recommended action back to Postfix.
#
#  Tumgreyspf source
#  Copyright (c) 2004-2005, Sean Reifschneider, tummy.com, ltd.
#  <jafo@tummy.com>
#
#  pypolicyd-spf
#  Copyright (c) 2007, 2008 Scott Kitterman <scott@kitterman.com>
'''
    This program is free software; you can redistribute it and/or modify
    it under the terms of the GNU General Public License version 2 as published 
    by the Free Software Foundation.

    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.'''

__version__ = "0.7: Jun 20 2008"

import syslog, os, sys, string, re, time, popen2, urllib, stat, errno, socket, spf
import policydspfsupp

syslog.openlog(os.path.basename(sys.argv[0]), syslog.LOG_PID, syslog.LOG_MAIL)
policydspfsupp.setExceptHook()

#############################################
def cidrmatch(connectip, ipaddrs, n):
    """Match connect IP against a list of other IP addresses. From pyspf."""

    try:
        if connectip.count(':'):
            MASK = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFL
            connectip = spf.inet_pton(connectip)
            for arg in ipaddrs:
                ipaddrs[ipaddrs.index(arg)] = spf.inet_pton(arg)
            bin = spf.bin2long6
        else:
            MASK = 0xFFFFFFFFL
            bin = spf.addr2bin
        c = ~(MASK >> n) & MASK & bin(connectip)
        for ip in [bin(ip) for ip in ipaddrs]:
            if c == ~(MASK >> n) & MASK & ip: return True
    except socket.error: pass
    return False

def parse_cidr(cidr_ip):
    """Breaks CIDR notation into a (address,cidr,cidr6) tuple.  The cidr 
       defaults to 32 if not present. Derived from pyspf"""
    import re
    RE_DUAL_CIDR = re.compile(r'//(0|[1-9]\d*)$')
    RE_CIDR = re.compile(r'/(0|[1-9]\d*)$')
    a = RE_DUAL_CIDR.split(cidr_ip)
    if len(a) == 3:
        cidr_ip, cidr6 = a[0], int(a[1])
    else:
        cidr6 = None
    a = RE_CIDR.split(cidr_ip)
    if len(a) == 3:
        cidr_ip, cidr = a[0], int(a[1])
    else:
        cidr = None
    b = cidr_ip.split(':', 1)
    if len(b) < 2:
        return cidr_ip, cidr
    return a[0], cidr6
#############################################
def get_resultcodes(configData, scope):
    # Parse config options for SPF results to correct Posftix actions
    actions = {'defer':[], 'reject':[], 'prepend':[]}
    local = {'local_helo': False, 'local_mfrom': False}
    unused_results = ['Pass', 'None', 'Neutral', 'Softfail', 'Fail', 'Temperror', 'Permerror'] 
    helo_policy = ''
    mfrom_policy = ''
    reject_domain_list = []
    sender = data.get('sender')
    helo = data.get('helo_name')
    if configData.get('Reject_Not_Pass_Domains'):
        reject_domains = (str(configData.get('Reject_Not_Pass_Domains')))
        reject_domain_list = reject_domains.split(',')
        sender_domain = string.split(sender, '@', 1)
    
    if scope == 'helo':
        helo_policy = configData.get('HELO_reject')
        if spf.domainmatch(reject_domain_list, helo):
            helo_policy = 'SPF_Not_Pass'
            local['local_helo'] = True
        if helo_policy == 'SPF_Not_Pass':
            try:
                unused_results.remove('Fail')
                actions['reject'].append('Fail')
                unused_results.remove('Softfail')
                actions['reject'].append('Softfail')
                unused_results.remove('Neutral')
                actions['reject'].append('Neutral')
            except:
                if debugLevel >= 2: syslog.syslog('Configuration File parsing error: HELO_reject')
        elif helo_policy == 'Softfail':
            try:
                unused_results.remove('Fail')
                actions['reject'].append('Fail')
                unused_results.remove('Softfail')
                actions['reject'].append('Softfail')

            except:
                if debugLevel >= 2: syslog.syslog('Configuration File parsing error: HELO_reject')
        elif helo_policy == 'Fail' or helo_policy == 'Null':
            try:
                unused_results.remove('Fail')
                actions['reject'].append('Fail')
            except:
                if debugLevel >= 2: syslog.syslog('Configuration File parsing error: HELO_reject')
    if scope == 'mfrom':
        mfrom_policy = configData.get('Mail_From_reject')
        sender_domain = string.split(sender, '@', 1)
        if spf.domainmatch(reject_domain_list, sender_domain[1]):
            mfrom_policy = 'SPF_Not_Pass'
            local['local_mfrom'] = True
        if mfrom_policy == 'SPF_Not_Pass':
            try:
                unused_results.remove('Fail')
                actions['reject'].append('Fail')
                unused_results.remove('Softfail')
                actions['reject'].append('Softfail')
                unused_results.remove('Neutral')
                actions['reject'].append('Neutral')
            except:
                if debugLevel >= 2: syslog.syslog('Configuration File parsing error: Mail_From_reject')
        elif mfrom_policy == 'Softfail':
            try:
                unused_results.remove('Fail')
                actions['reject'].append('Fail')
                unused_results.remove('Softfail')
                actions['reject'].append('Softfail')

            except:
                if debugLevel >= 2: syslog.syslog('Configuration File parsing error: Mail_From_reject')
        elif mfrom_policy == 'Fail':
            try:
                unused_results.remove('Fail')
                actions['reject'].append('Fail')
            except:
                if debugLevel >= 2: syslog.syslog('Configuration File parsing error: Mail_From_reject')

    if (helo_policy == 'False' and scope == 'helo') or (mfrom_policy == 'False' and scope == 'mfrom'):
        for result in unused_results:
            actions['prepend'].append(result)
        return(actions, local)
    if configData.get('TempError_Defer') == 'True':
        actions['defer'].append('Temperror')
        unused_results.remove('Temperror')
    if configData.get('PermError_reject') == 'True':
        actions['reject'].append('Permerror')
        unused_results.remove('Permerror')
    for result in unused_results:
        actions['prepend'].append(result)
    return(actions, local)
#############################################
def spfbypass(data, check_type, configData):
    if configData.get(check_type):
        if configData.get('Prospective'):
            ip = configData.get('Prospective')
        else:
            ip = data.get('client_address')
        bypass_list = (str(configData.get(check_type)))
        bypass_list_list = bypass_list.split(',')
        if check_type == 'skip_addresses' or check_type == 'Whitelist':
            for cidr in bypass_list_list:
                parsed_address = parse_cidr(cidr)
                good_ip = [parsed_address[0],]
                cidr_range = parsed_address[1]
                if not parsed_address[1]:
                    ip_str = str(good_ip[0])
                    if spf.RE_IP4.match(ip_str):
                        cidr_range = 32
                    elif spf.RE_IP6.match(ip_str):
                        cidr_range = 128
                    else:
                        Message = 'ERROR: ' + ip_str + ' in ' + check_type + ' not IP address. Aborting whitelist processing.'
                        if debugLevel: syslog.syslog(Message)
                        return (False, 'None')
                if cidrmatch(ip, good_ip, int(cidr_range)):
                    if check_type == 'skip_addresses':
                        comment = 'SPF check N/A for local connections - '
                    else:
                        comment = 'SPF skipped for whitelisted relay - '
                    Header = ('X-Comment: ' + comment + 'client-ip=%s; helo=%s; envelope-from=%s; receiver=%s '
                        % ( data.get('client_address', '<UNKNOWN>'),
                            data.get('helo_name', '<UNKNOWN>'),
                            data.get('sender', '<UNKNOWN>'),
                            data.get('recipient', '<UNKNOWN>'),
                            ))
                    if debugLevel >= 3: syslog.syslog(Header)
                    return (True, Header)
            return (False, 'None')
        elif check_type == 'Domain_Whitelist':
            for domain in bypass_list_list:
                res = spf.check2(ip, domain, domain)
                domain_res = [res[0], res[1]]
                domain_res[0] = domain_res[0].lower()
                domain_res[0] = domain_res[0].capitalize()
                if domain_res[0] == 'Pass':
                    comment = 'SPF skipped for whitelisted relay domain - '
                    Header = ('X-Comment: ' + comment + 'client-ip=%s; helo=%s; envelope-from=%s; receiver=%s '
                        % ( data.get('client_address', '<UNKNOWN>'),
                            data.get('helo_name', '<UNKNOWN>'),
                            data.get('sender', '<UNKNOWN>'),
                            data.get('recipient', '<UNKNOWN>'),
                            ))
                    if debugLevel >= 3: syslog.syslog(Header)
                    return (True, Header)
            return (False, 'None')
#############################################
def spfcheck(data, instance_dict, configData):  #{{{1
    debugLevel = configData.get('debugLevel', 1)
    if configData.get('Prospective'):
        ip = configData.get('Prospective')
    else:
        ip = data.get('client_address')
    if ip == None:
        if debugLevel >= 2: syslog.syslog('spfcheck: No client address, exiting')
        return(( None, None, instance_dict ))
    # Do not check SPF for localhost addresses 
    skip_check = spfbypass(data, 'skip_addresses', configData)
    if skip_check[0]:
        return (('prepend', skip_check[1], instance_dict ))
    # Whitelist designated IP addresses from SPF checks (e.g. secondary MX or 
    # known forwarders.
    ip_whitelist = spfbypass(data, 'Whitelist', configData)
    if ip_whitelist:
        if ip_whitelist[0]:
            return (('prepend', ip_whitelist[1], instance_dict ))
    # Whitelist designated Domain's sending addresses from SPF checks (e.g. 
    # known forwarders.
    domain_whitelist = spfbypass(data, 'Domain_Whitelist', configData)
    if domain_whitelist:
        if domain_whitelist[0]:
            return (('prepend', domain_whitelist[1], instance_dict ))
    #recipient = data.get('recipient')
    receiver=socket.gethostname()
    sender = data.get('sender')
    helo = data.get('helo_name')
    if not sender and not helo:
        if debugLevel >= 2: syslog.syslog('spfcheck: No sender or helo, exiting')
        return(( None, None, instance_dict ))

    #  start query
    spfResult = None
    spfReason = None
    instance = data.get('instance')
    # The following if is only needed for testing.  Postfix 
    # will always provide instance.
    if not instance:
        import random
        instance = str(int(random.random()*100000))
    # This is to prevent multiple headers being prepended
    # for multi-recipient mail.
    found_instance = instance_dict.has_key(instance)
    '''Data structure for results is a list of:
        [0] SPF result 
        [1] SPF reason
        [2] Identity (HELO/Mail From)
        [3] Action based on local policy
        [4] Header'''
    if not found_instance:
        Mail_From_pass_restriction = configData.get('Mail_From_pass_restriction')
        HELO_pass_restriction = configData.get('HELO_pass_restriction')
        helo_result = ['None',]
        # First do HELO check
        #  if no helo name sent, use domain from sender for later use.
        if not helo:
            foo = string.split(sender, '@', 1)
            if len(foo) <  2: helo = 'unknown'
            else: helo = foo[1]
        else:
            if configData.get('HELO_reject') != 'No_Check':
                helo_fake_sender = 'postmaster@' + helo
                res = spf.check2(ip, helo_fake_sender, helo)
                helo_result = [res[0], res[1]]
                helo_result.append('helo') 
                helo_result[0] = helo_result[0].lower()
                helo_result[0] = helo_result[0].capitalize()
                helo_resultpolicy, local = get_resultcodes(configData, 'helo')
                if debugLevel >= 2:
                    syslog.syslog('spfcheck: pyspf result: "%s"' % str(helo_result))
                if configData.get('HELO_reject') == 'Null' and sender:
                    helo_result.append('dunno')
                else:
                    for poss_actions in helo_resultpolicy:
                        if helo_result[0] in helo_resultpolicy[poss_actions]:
                            action = poss_actions
                            helo_result.append(action)
                    if local['local_helo']:
                        helo_result[1] = 'Receiver policy for SPF ' + helo_result[0]

                if sender == '':
                    header_sender = '<>'
                else:
                    header_sender = sender
                if helo_result[0] == 'None':
                    helo_result[1] = "no SPF record"
                spfDetail = ('identity=%s; client-ip=%s; helo=%s; envelope-from=%s; receiver=%s '
                    % (helo_result[2], ip, helo, header_sender, data.get('recipient', '<UNKNOWN>')))
                if debugLevel >= 1:
                    logdata = str(helo_result[0]) + "; " + spfDetail
                    syslog.syslog(logdata)
                header = 'Received-SPF: '+ helo_result[0] + ' (' + helo_result[1] +') ' + spfDetail
                helo_result.append(header)
                instance_dict[instance] = helo_result
                if HELO_pass_restriction and helo_result[0] == 'Pass':
                    restrict_name = HELO_pass_restriction
                # Only act on the HELO result if it is authoritative.
                if helo_result[3] == 'reject':
                    header = "Message rejected due to: " + helo_result[1] + ". Please see http://www.openspf.org/Why?s=helo;id=" + helo + ";ip=" + ip + ";r=" + data.get('recipient')
                    return(( 'reject', header, instance_dict ))
                if helo_result[3] == 'defer':
                    header = "Message deferred due to: " + helo_result[1] + ". Please see http://www.openspf.org/Why?s=helo;id=" + helo + ";ip=" + ip + ";r=" + data.get('recipient')
                    return(( 'defer', header, instance_dict ))
        # Second do Mail From Check
        if sender == '':
            if configData.get('HELO_reject') != 'No_Check':
                if helo_result[3] == 'reject':
                    header = "Message rejected due to: " + helo_result[1] + ". Please see http://www.openspf.org/Why?s=helo;id=" + helo + ";ip=" + ip + ";r=" + data.get('recipient')
                if helo_result[3] == 'defer':
                    header = "Message deferred due to: " + helo_result[1] + ". Please see http://www.openspf.org/Why?s=helo;id=" + helo + ";ip=" + ip + ";r=" + data.get('recipient')
                if HELO_pass_restriction and helo_result[0] == 'Pass':
                    restrict_name = HELO_pass_restriction
                    return('result_only', restrict_name, instance_dict)
                return(( helo_result[3], header, instance_dict ))
        else:
            if configData.get('Mail_From_reject') != 'No_Check':
                res = spf.check2(ip, sender, helo)
                mfrom_result = [res[0], res[1]]
                mfrom_result.append('mailfrom')
                mfrom_result[0] = mfrom_result[0].lower()
                mfrom_result[0] = mfrom_result[0].capitalize()
                mfrom_resultpolicy, local = get_resultcodes(configData, 'mfrom')
                if debugLevel >= 2:
                    syslog.syslog('spfcheck: pyspf result: "%s"' % str(mfrom_result))
                for poss_actions in mfrom_resultpolicy:
                    if mfrom_result[0] in mfrom_resultpolicy[poss_actions]:
                        action = poss_actions
                        mfrom_result.append(action)
                if local['local_mfrom']:
                    mfrom_result[1] = 'Receiver policy for SPF ' + mfrom_result[0]
                if mfrom_result[0] == 'None':
                    mfrom_result[1] = 'no SPF record'
                if mfrom_result[0] != 'None' or (mfrom_result[0] == 'None' and helo_result[0] == 'None'):
                    spfDetail = \
                        ('identity=%s; client-ip=%s; helo=%s; envelope-from=%s; receiver=%s '
                        % (mfrom_result[2], ip, helo, sender, data.get('recipient', '<UNKNOWN>')))
                    if debugLevel >= 1:
                        logdata = str(mfrom_result[0]) + "; " + spfDetail
                        syslog.syslog(logdata)
                    header = 'Received-SPF: '+ mfrom_result[0] + ' (' + mfrom_result[1] +') ' + spfDetail
                    mfrom_result.append(header)
                    instance_dict[instance] = mfrom_result
                if (Mail_From_pass_restriction and mfrom_result[0] == 'Pass') or (HELO_pass_restriction and helo_result[0] == 'Pass'):
                    if mfrom_result[0] == 'Pass':
                        restrict_name = Mail_From_pass_restriction
                    return('result_only', restrict_name, instance_dict)
                # Act on the Mail From result if it is authoritative.
                if mfrom_result[3] == 'reject':
                    header = "Message rejected due to: " + mfrom_result[1] + ". Please see http://www.openspf.org/Why?s=mfrom;id=" + sender + ";ip=" + ip + ";r=" + data.get('recipient')
                    return(( 'reject', header, instance_dict ))
                if mfrom_result[3] == 'defer':
                    header = "Message deferred due to: " + mfrom_result[1] + ". Please see http://www.openspf.org/Why?s=mfrom;id=" + sender + ";ip=" + ip + ";r=" + data.get('recipient')
                    return(( 'defer', header, instance_dict ))
                if mfrom_result[3] != 'dunno' or helo_result[3] =='dunno':
                    return(( 'prepend', header, instance_dict ))
    else:
        cached_instance = instance_dict[instance]
        if cached_instance[3] == 'prepend':
            return(( 'dunno', 'Header already pre-pended', instance_dict ))
        else:
            return(( cached_instance[3], cached_instance[4], instance_dict ))
    return(( 'None', 'None', instance_dict ))

###################################################
#  load config file  {{{1
#  Default location:
configFile = '/etc/python-policyd-spf/policyd-spf.conf'
if len(sys.argv) > 1:
    if sys.argv[1] in ( '-?', '--help', '-h' ):
        print 'usage: policyd-spf [<configfilename>]'
        sys.exit(1)
    configFile = sys.argv[1]

configGlobal = policydspfsupp.processConfigFile(filename = configFile)

#  loop reading data  {{{1
debugLevel = configGlobal.get('debugLevel', 1)
if debugLevel >= 3: syslog.syslog('Starting')
instance_dict = {'0':'init',}
instance_dict.clear()
data = {}
lineRx = re.compile(r'^\s*([^=\s]+)\s*=(.*)$')
while 1:
    line = sys.stdin.readline()
    if not line: break
    line = string.rstrip(line)
    if debugLevel >= 4: syslog.syslog('Read line: "%s"' % line)

    #  end of entry  {{{2
    if not line:
        if debugLevel >= 4: syslog.syslog('Found the end of entry')
        configData = configGlobal
        if debugLevel >= 3: syslog.syslog('Config: %s' % str(configData))

        #  run the checkers  {{{3
        checkerValue = None
        checkerReason = None
        checkerValue, checkerReason, instance_dict = spfcheck(data, 
                    instance_dict, configData)
        if configData.get('defaultSeedOnly') == 0:
            checkerValue = None
            checkerReason = None

        #  handle results  {{{3
        if checkerValue == 'reject':
            sys.stdout.write('action=550 %s\n\n' % checkerReason)

        elif checkerValue == 'prepend':
            if configData.get('Prospective'):
                sys.stdout.write('action=dunno\n\n')
            else:
                sys.stdout.write('action=prepend %s\n\n' % checkerReason)

        elif checkerValue == 'defer':
            sys.stdout.write('action=defer_if_permit %s\n\n' % checkerReason)

        elif checkerValue == 'warn':
            sys.stdout.write('action=warn %s\n\n' % checkerReason)

        elif checkerValue == 'result_only':
            sys.stdout.write('action=%s\n\n' % checkerReason)
        else:
            sys.stdout.write('action=dunno\n\n')

        #  end of record  {{{3
        sys.stdout.flush()
        data = {}
        continue

    #  parse line  {{{2
    m = lineRx.match(line)
    if not m: 
        syslog.syslog('ERROR: Could not match line "%s"' % line)
        continue

    #  save the string  {{{2
    key = m.group(1)
    value = m.group(2)
    if key not in [ 'protocol_state', 'protocol_name', 'queue_id' ]:
        value = string.lower(value)
    data[key] = value

if debugLevel >= 3: syslog.syslog('Normal exit')
