Add config file to spfmilter
This commit is contained in:
@@ -0,0 +1,59 @@
|
|||||||
|
from ConfigParser import ConfigParser
|
||||||
|
|
||||||
|
class MilterConfigParser(ConfigParser):
|
||||||
|
|
||||||
|
def __init__(self,defaults={}):
|
||||||
|
ConfigParser.__init__(self)
|
||||||
|
self.defaults = defaults
|
||||||
|
|
||||||
|
# The defaults provided by ConfigParser show up in all sections,
|
||||||
|
# which screws up iterating over all options in a section.
|
||||||
|
# Worse, passing "defaults" with vars= overrides the config file!
|
||||||
|
# So we roll our own defaults.
|
||||||
|
def get(self,sect,opt):
|
||||||
|
if not self.has_option(sect,opt) and opt in self.defaults:
|
||||||
|
return self.defaults[opt]
|
||||||
|
return ConfigParser.get(self,sect,opt)
|
||||||
|
|
||||||
|
def getlist(self,sect,opt):
|
||||||
|
if self.has_option(sect,opt):
|
||||||
|
return [q.strip() for q in self.get(sect,opt).split(',')]
|
||||||
|
return []
|
||||||
|
|
||||||
|
def getaddrset(self,sect,opt):
|
||||||
|
if not self.has_option(sect,opt):
|
||||||
|
return {}
|
||||||
|
s = self.get(sect,opt)
|
||||||
|
d = {}
|
||||||
|
for q in s.split(','):
|
||||||
|
q = q.strip()
|
||||||
|
if q.startswith('file:'):
|
||||||
|
domain = q[5:].lower()
|
||||||
|
d[domain] = d.setdefault(domain,[]) + open(domain,'r').read().split()
|
||||||
|
else:
|
||||||
|
user,domain = q.split('@')
|
||||||
|
d.setdefault(domain.lower(),[]).append(user)
|
||||||
|
return d
|
||||||
|
|
||||||
|
def getaddrdict(self,sect,opt):
|
||||||
|
if not self.has_option(sect,opt):
|
||||||
|
return {}
|
||||||
|
d = {}
|
||||||
|
for q in self.get(sect,opt).split(','):
|
||||||
|
q = q.strip()
|
||||||
|
if self.has_option(sect,q):
|
||||||
|
l = self.get(sect,q)
|
||||||
|
for addr in l.split(','):
|
||||||
|
addr = addr.strip()
|
||||||
|
if addr.startswith('file:'):
|
||||||
|
fname = addr[5:]
|
||||||
|
for a in open(fname,'r').read().split():
|
||||||
|
d[a] = q
|
||||||
|
else:
|
||||||
|
d[addr] = q
|
||||||
|
return d
|
||||||
|
|
||||||
|
def getdefault(self,sect,opt,default=None):
|
||||||
|
if self.has_option(sect,opt):
|
||||||
|
return self.get(sect,opt)
|
||||||
|
return default
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
# A simple milter that has grown quite a bit.
|
# A simple milter that has grown quite a bit.
|
||||||
# $Log$
|
# $Log$
|
||||||
|
# Revision 1.81 2007/01/05 23:33:55 customdesigned
|
||||||
|
# Make blacklist an AddrCache
|
||||||
|
#
|
||||||
# Revision 1.80 2007/01/05 23:12:12 customdesigned
|
# Revision 1.80 2007/01/05 23:12:12 customdesigned
|
||||||
# Move parse_addr, iniplist, ip4re to Milter.utils
|
# Move parse_addr, iniplist, ip4re to Milter.utils
|
||||||
#
|
#
|
||||||
@@ -49,7 +52,6 @@ import mime
|
|||||||
import email.Errors
|
import email.Errors
|
||||||
import Milter
|
import Milter
|
||||||
import tempfile
|
import tempfile
|
||||||
from ConfigParser import ConfigParser
|
|
||||||
import time
|
import time
|
||||||
import socket
|
import socket
|
||||||
import struct
|
import struct
|
||||||
@@ -60,6 +62,7 @@ import anydbm
|
|||||||
import Milter.dsn as dsn
|
import Milter.dsn as dsn
|
||||||
from Milter.dynip import is_dynip as dynip
|
from Milter.dynip import is_dynip as dynip
|
||||||
from Milter.utils import iniplist,parse_addr,ip4re
|
from Milter.utils import iniplist,parse_addr,ip4re
|
||||||
|
from Milter.config import MilterConfigParser
|
||||||
|
|
||||||
from fnmatch import fnmatchcase
|
from fnmatch import fnmatchcase
|
||||||
from email.Header import decode_header
|
from email.Header import decode_header
|
||||||
@@ -167,64 +170,6 @@ milter_log = logging.getLogger('milter')
|
|||||||
if gossip:
|
if gossip:
|
||||||
gossip_node = Gossip('gossip4.db',120)
|
gossip_node = Gossip('gossip4.db',120)
|
||||||
|
|
||||||
class MilterConfigParser(ConfigParser):
|
|
||||||
|
|
||||||
def __init__(self,defaults):
|
|
||||||
ConfigParser.__init__(self)
|
|
||||||
self.defaults = defaults
|
|
||||||
|
|
||||||
# The defaults provided by ConfigParser show up in all sections,
|
|
||||||
# which screws up iterating over all options in a section.
|
|
||||||
# Worse, passing "defaults" with vars= overrides the config file!
|
|
||||||
# So we roll our own defaults.
|
|
||||||
def get(self,sect,opt):
|
|
||||||
if not self.has_option(sect,opt) and opt in self.defaults:
|
|
||||||
return self.defaults[opt]
|
|
||||||
return ConfigParser.get(self,sect,opt)
|
|
||||||
|
|
||||||
def getlist(self,sect,opt):
|
|
||||||
if self.has_option(sect,opt):
|
|
||||||
return [q.strip() for q in self.get(sect,opt).split(',')]
|
|
||||||
return []
|
|
||||||
|
|
||||||
def getaddrset(self,sect,opt):
|
|
||||||
if not self.has_option(sect,opt):
|
|
||||||
return {}
|
|
||||||
s = self.get(sect,opt)
|
|
||||||
d = {}
|
|
||||||
for q in s.split(','):
|
|
||||||
q = q.strip()
|
|
||||||
if q.startswith('file:'):
|
|
||||||
domain = q[5:].lower()
|
|
||||||
d[domain] = d.setdefault(domain,[]) + open(domain,'r').read().split()
|
|
||||||
else:
|
|
||||||
user,domain = q.split('@')
|
|
||||||
d.setdefault(domain.lower(),[]).append(user)
|
|
||||||
return d
|
|
||||||
|
|
||||||
def getaddrdict(self,sect,opt):
|
|
||||||
if not self.has_option(sect,opt):
|
|
||||||
return {}
|
|
||||||
d = {}
|
|
||||||
for q in self.get(sect,opt).split(','):
|
|
||||||
q = q.strip()
|
|
||||||
if self.has_option(sect,q):
|
|
||||||
l = self.get(sect,q)
|
|
||||||
for addr in l.split(','):
|
|
||||||
addr = addr.strip()
|
|
||||||
if addr.startswith('file:'):
|
|
||||||
fname = addr[5:]
|
|
||||||
for a in open(fname,'r').read().split():
|
|
||||||
d[a] = q
|
|
||||||
else:
|
|
||||||
d[addr] = q
|
|
||||||
return d
|
|
||||||
|
|
||||||
def getdefault(self,sect,opt,default=None):
|
|
||||||
if self.has_option(sect,opt):
|
|
||||||
return self.get(sect,opt)
|
|
||||||
return default
|
|
||||||
|
|
||||||
def read_config(list):
|
def read_config(list):
|
||||||
cp = MilterConfigParser({
|
cp = MilterConfigParser({
|
||||||
'tempdir': "/var/log/milter/save",
|
'tempdir': "/var/log/milter/save",
|
||||||
@@ -393,7 +338,7 @@ def parse_header(val):
|
|||||||
return val
|
return val
|
||||||
|
|
||||||
class SPFPolicy(object):
|
class SPFPolicy(object):
|
||||||
"Get SPF policy by result, defaulting to classic policy from pymilter.cfg"
|
"Get SPF policy by result from sendmail style access file."
|
||||||
def __init__(self,sender):
|
def __init__(self,sender):
|
||||||
self.sender = sender
|
self.sender = sender
|
||||||
self.domain = sender.split('@')[-1].lower()
|
self.domain = sender.split('@')[-1].lower()
|
||||||
|
|||||||
+1
-1
@@ -78,7 +78,7 @@ reject_spoofed = 0
|
|||||||
# refuses mail from user names commonly abused in that way.
|
# refuses mail from user names commonly abused in that way.
|
||||||
;banned_users = postmaster, mailer-daemon, clamav
|
;banned_users = postmaster, mailer-daemon, clamav
|
||||||
|
|
||||||
# See http://spf.pobox.com for more info on SPF.
|
# See http://www.openspf.com for more info on SPF.
|
||||||
[spf]
|
[spf]
|
||||||
# namespace where SPF records can be supplied for domains without one
|
# namespace where SPF records can be supplied for domains without one
|
||||||
# records are searched for under _spf.domain.com
|
# records are searched for under _spf.domain.com
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
[milter]
|
||||||
|
# The socket used to communicate with sendmail
|
||||||
|
socketname = /tmp/spfmiltersock
|
||||||
|
# Name of the milter given to sendmail
|
||||||
|
name = pyspffilter
|
||||||
|
# Trusted relays such as secondary MXes that should not have SPF checked.
|
||||||
|
;trusted_relay =
|
||||||
|
# Internal networks that should not have SPF checked.
|
||||||
|
internal_connect = 127.0.0.1,192.168.0.0/16
|
||||||
|
|
||||||
|
# See http://www.openspf.com for more info on SPF.
|
||||||
|
[spf]
|
||||||
|
# Use sendmail access map or similar format for detailed spf policy.
|
||||||
|
# SPF entries in the access map will override defaults.
|
||||||
|
;access_file = /etc/mail/access.db
|
||||||
|
# Connections that get an SPF pass for a pretend MAIL FROM of
|
||||||
|
# postmaster@sometrustedforwarder.com skip SPF checks for the real MAIL FROM.
|
||||||
|
# This is for non-SRS forwarders. It is a simple implementation that
|
||||||
|
# is inefficient for more than a few entries.
|
||||||
|
;trusted_forwarder = careerbuilder.com
|
||||||
+102
-34
@@ -13,26 +13,55 @@ import spf
|
|||||||
import struct
|
import struct
|
||||||
import socket
|
import socket
|
||||||
import syslog
|
import syslog
|
||||||
|
from Milter.config import MilterConfigParser
|
||||||
from Milter.utils import iniplist,parse_addr
|
from Milter.utils import iniplist,parse_addr
|
||||||
|
|
||||||
syslog.openlog('spfmilter',0,syslog.LOG_MAIL)
|
syslog.openlog('spfmilter',0,syslog.LOG_MAIL)
|
||||||
|
|
||||||
# list of trusted forwarder domains. An SPF record for a forwarder
|
class Config(object):
|
||||||
# domain lists IP addresses from which forwarded mail is accepted.
|
"Hold configuration options."
|
||||||
trusted_forwarder = []
|
pass
|
||||||
# list of internal LAN ips. No SPF check is done for these.
|
|
||||||
internal_connect = ['127.0.0.1','192.168.0.0/16']
|
|
||||||
# list of trusted relays. These are typically secondary MXes, and
|
|
||||||
# no SPF check is done for these.
|
|
||||||
trusted_relay = []
|
|
||||||
|
|
||||||
socketname = "/var/run/milter/spfmiltersock"
|
def read_config(list):
|
||||||
#socketname = os.getenv("HOME") + "/pythonsock"
|
"Return new config object."
|
||||||
miltername = "pyspffilter"
|
cp = MilterConfigParser()
|
||||||
|
cp.read(list)
|
||||||
|
conf = Config()
|
||||||
|
conf.socketname = cp.getdefault('milter','socketname', '/tmp/spfmiltersock')
|
||||||
|
conf.miltername = cp.getdefault('milter','name','pyspffilter')
|
||||||
|
conf.trusted_relay = cp.getlist('milter','trusted_relay')
|
||||||
|
conf.internal_connect = cp.getlist('milter','internal_connect')
|
||||||
|
conf.trusted_forwarder = cp.getlist('spf','trusted_relay')
|
||||||
|
conf.access_file = cp.getdefault('spf','access_file',None)
|
||||||
|
return conf
|
||||||
|
|
||||||
|
class SPFPolicy(object):
|
||||||
|
"Get SPF policy by result from sendmail style access file."
|
||||||
|
def __init__(self,sender):
|
||||||
|
self.sender = sender
|
||||||
|
self.domain = sender.split('@')[-1].lower()
|
||||||
|
if access_file:
|
||||||
|
try: acf = anydbm.open(access_file,'r')
|
||||||
|
except: acf = None
|
||||||
|
else: acf = None
|
||||||
|
self.acf = acf
|
||||||
|
|
||||||
|
def getPolicy(self,pfx):
|
||||||
|
acf = self.acf
|
||||||
|
if not acf: return None
|
||||||
|
try:
|
||||||
|
return acf[pfx + self.sender]
|
||||||
|
except KeyError:
|
||||||
|
try:
|
||||||
|
return acf[pfx + self.domain]
|
||||||
|
except KeyError:
|
||||||
|
try:
|
||||||
|
return acf[pfx]
|
||||||
|
except KeyError:
|
||||||
|
return None
|
||||||
|
|
||||||
class spfMilter(Milter.Milter):
|
class spfMilter(Milter.Milter):
|
||||||
"Milter to check SPF."
|
"Milter to check SPF. Each connection gets its own instance."
|
||||||
|
|
||||||
def log(self,*msg):
|
def log(self,*msg):
|
||||||
syslog.syslog('[%d] %s' % (self.id,' '.join([str(m) for m in msg])))
|
syslog.syslog('[%d] %s' % (self.id,' '.join([str(m) for m in msg])))
|
||||||
@@ -40,6 +69,8 @@ class spfMilter(Milter.Milter):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.mailfrom = None
|
self.mailfrom = None
|
||||||
self.id = Milter.uniqueID()
|
self.id = Milter.uniqueID()
|
||||||
|
# we don't want config used to change during a connection
|
||||||
|
self.conf = config
|
||||||
|
|
||||||
# addheader can only be called from eom(). This accumulates added headers
|
# addheader can only be called from eom(). This accumulates added headers
|
||||||
# which can then be applied by alter_headers()
|
# which can then be applied by alter_headers()
|
||||||
@@ -55,9 +86,9 @@ class spfMilter(Milter.Milter):
|
|||||||
self.receiver = self.getsymval('j').strip()
|
self.receiver = self.getsymval('j').strip()
|
||||||
if hostaddr and len(hostaddr) > 0:
|
if hostaddr and len(hostaddr) > 0:
|
||||||
ipaddr = hostaddr[0]
|
ipaddr = hostaddr[0]
|
||||||
if iniplist(ipaddr,internal_connect):
|
if iniplist(ipaddr,self.conf.internal_connect):
|
||||||
self.internal_connection = True
|
self.internal_connection = True
|
||||||
if iniplist(ipaddr,trusted_relay):
|
if iniplist(ipaddr,self.conf.trusted_relay):
|
||||||
self.trusted_relay = True
|
self.trusted_relay = True
|
||||||
else: ipaddr = ''
|
else: ipaddr = ''
|
||||||
self.connectip = ipaddr
|
self.connectip = ipaddr
|
||||||
@@ -112,7 +143,7 @@ class spfMilter(Milter.Milter):
|
|||||||
|
|
||||||
def check_spf(self):
|
def check_spf(self):
|
||||||
receiver = self.receiver
|
receiver = self.receiver
|
||||||
for tf in trusted_forwarder:
|
for tf in self.conf.trusted_forwarder:
|
||||||
q = spf.query(self.connectip,'',tf,receiver=receiver,strict=False)
|
q = spf.query(self.connectip,'',tf,receiver=receiver,strict=False)
|
||||||
res,code,txt = q.check()
|
res,code,txt = q.check()
|
||||||
if res == 'pass':
|
if res == 'pass':
|
||||||
@@ -141,25 +172,58 @@ class spfMilter(Milter.Milter):
|
|||||||
else:
|
else:
|
||||||
hres,hcode,htxt = res,code,txt
|
hres,hcode,htxt = res,code,txt
|
||||||
else: hres = None
|
else: hres = None
|
||||||
|
|
||||||
|
p = SPFPolicy(q.s)
|
||||||
|
|
||||||
if res == 'fail':
|
if res == 'fail':
|
||||||
self.log('REJECT: SPF %s %i %s' % (res,code,txt))
|
policy = p.getPolicy('spf-fail:')
|
||||||
self.setreply(str(code),'5.7.1',txt)
|
if not policy or policy == 'REJECT':
|
||||||
# A proper SPF fail error message would read:
|
self.log('REJECT: SPF %s %i %s' % (res,code,txt))
|
||||||
# forger.biz [1.2.3.4] is not allowed to send mail with the domain
|
self.setreply(str(code),'5.7.1',txt)
|
||||||
# "forged.org" in the sender address. Contact <postmaster@forged.org>.
|
# A proper SPF fail error message would read:
|
||||||
return Milter.REJECT
|
# forger.biz [1.2.3.4] is not allowed to send mail with the domain
|
||||||
if res == 'permerror':
|
# "forged.org" in the sender address. Contact <postmaster@forged.org>.
|
||||||
self.log('REJECT: SPF %s %i %s' % (res,code,txt))
|
return Milter.REJECT
|
||||||
# latest SPF draft recommends 5.5.2 instead of 5.7.1
|
if res == 'softfail':
|
||||||
self.setreply(str(code),'5.5.2',txt,
|
policy = p.getPolicy('spf-softfail:')
|
||||||
'There is a fatal syntax error in the SPF record for %s' % q.o,
|
if policy and policy == 'REJECT':
|
||||||
'We cannot accept mail from %s until this is corrected.' % q.o
|
self.log('REJECT: SPF %s %i %s' % (res,code,txt))
|
||||||
)
|
self.setreply(str(code),'5.7.1',txt)
|
||||||
return Milter.REJECT
|
# A proper SPF fail error message would read:
|
||||||
if res == 'temperror':
|
# forger.biz [1.2.3.4] is not allowed to send mail with the domain
|
||||||
self.log('TEMPFAIL: SPF %s %i %s' % (res,code,txt))
|
# "forged.org" in the sender address. Contact <postmaster@forged.org>.
|
||||||
self.setreply(str(code),'4.3.0',txt)
|
return Milter.REJECT
|
||||||
return Milter.TEMPFAIL
|
elif res == 'permerror':
|
||||||
|
policy = p.getPolicy('spf-permerror:')
|
||||||
|
if not policy or policy == 'REJECT':
|
||||||
|
self.log('REJECT: SPF %s %i %s' % (res,code,txt))
|
||||||
|
# latest SPF draft recommends 5.5.2 instead of 5.7.1
|
||||||
|
self.setreply(str(code),'5.5.2',txt,
|
||||||
|
'There is a fatal syntax error in the SPF record for %s' % q.o,
|
||||||
|
'We cannot accept mail from %s until this is corrected.' % q.o
|
||||||
|
)
|
||||||
|
return Milter.REJECT
|
||||||
|
elif res == 'temperror':
|
||||||
|
policy = p.getPolicy('spf-temperror:')
|
||||||
|
if not policy or policy == 'REJECT':
|
||||||
|
self.log('TEMPFAIL: SPF %s %i %s' % (res,code,txt))
|
||||||
|
self.setreply(str(code),'4.3.0',txt)
|
||||||
|
return Milter.TEMPFAIL
|
||||||
|
elif res == 'neutral' or res == 'none':
|
||||||
|
policy = p.getPolicy('spf-neutral:')
|
||||||
|
if policy and policy == 'REJECT':
|
||||||
|
self.log('REJECT NEUTRAL:',q.s)
|
||||||
|
self.setreply('550','5.7.1',
|
||||||
|
"%s requires and SPF PASS to accept mail from %s. [http://openspf.org]"
|
||||||
|
% (receiver,q.s))
|
||||||
|
return Milter.REJECT
|
||||||
|
elif res == 'pass':
|
||||||
|
policy = p.getPolicy('spf-pass:')
|
||||||
|
if policy and policy == 'REJECT':
|
||||||
|
self.log('REJECT PASS:',q.s)
|
||||||
|
self.setreply('550','5.7.1',
|
||||||
|
"%s has been blacklisted by %s." % (q.s,receiver))
|
||||||
|
return Milter.REJECT
|
||||||
self.add_header('Received-SPF',q.get_header(res,receiver),0)
|
self.add_header('Received-SPF',q.get_header(res,receiver),0)
|
||||||
if hres and q.h != q.o:
|
if hres and q.h != q.o:
|
||||||
self.add_header('X-Hello-SPF',hres,0)
|
self.add_header('X-Hello-SPF',hres,0)
|
||||||
@@ -168,6 +232,10 @@ class spfMilter(Milter.Milter):
|
|||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
Milter.factory = spfMilter
|
Milter.factory = spfMilter
|
||||||
Milter.set_flags(Milter.CHGHDRS + Milter.ADDHDRS)
|
Milter.set_flags(Milter.CHGHDRS + Milter.ADDHDRS)
|
||||||
|
global config
|
||||||
|
config = read_config(['spfmilter.cfg','/etc/mail/spfmilter.cfg'])
|
||||||
|
miltername = config.miltername
|
||||||
|
socketname = config.socketname
|
||||||
print """To use this with sendmail, add the following to sendmail.cf:
|
print """To use this with sendmail, add the following to sendmail.cf:
|
||||||
|
|
||||||
O InputMailFilters=%s
|
O InputMailFilters=%s
|
||||||
|
|||||||
Reference in New Issue
Block a user