From 8ae7bd421786135f15e001c19e76600e30a0e20d Mon Sep 17 00:00:00 2001
From: Stuart Gathman <stuart@gathman.org>
Date: Sat, 6 Jan 2007 04:21:30 +0000
Subject: [PATCH] Add config file to spfmilter

---
 Milter/config.py |  59 ++++++++++++++++++++
 bms.py           |  65 ++--------------------
 milter.cfg       |   2 +-
 spfmilter.cfg    |  20 +++++++
 spfmilter.py     | 140 +++++++++++++++++++++++++++++++++++------------
 5 files changed, 189 insertions(+), 97 deletions(-)
 create mode 100644 Milter/config.py
 create mode 100644 spfmilter.cfg

diff --git a/Milter/config.py b/Milter/config.py
new file mode 100644
index 0000000..9347c9a
--- /dev/null
+++ b/Milter/config.py
@@ -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
diff --git a/bms.py b/bms.py
index 9a430a5..3c61d83 100644
--- a/bms.py
+++ b/bms.py
@@ -1,6 +1,9 @@
 #!/usr/bin/env python
 # A simple milter that has grown quite a bit.
 # $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
 # Move parse_addr, iniplist, ip4re to Milter.utils
 #
@@ -49,7 +52,6 @@ import mime
 import email.Errors
 import Milter
 import tempfile
-from ConfigParser import ConfigParser
 import time
 import socket
 import struct
@@ -60,6 +62,7 @@ import anydbm
 import Milter.dsn as dsn
 from Milter.dynip import is_dynip as dynip
 from Milter.utils import iniplist,parse_addr,ip4re
+from Milter.config import MilterConfigParser
 
 from fnmatch import fnmatchcase
 from email.Header import decode_header
@@ -167,64 +170,6 @@ milter_log = logging.getLogger('milter')
 if gossip:
   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):
   cp = MilterConfigParser({
     'tempdir': "/var/log/milter/save",
@@ -393,7 +338,7 @@ def parse_header(val):
   return val
 
 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):
     self.sender = sender
     self.domain = sender.split('@')[-1].lower()
diff --git a/milter.cfg b/milter.cfg
index 306b799..0553fb9 100644
--- a/milter.cfg
+++ b/milter.cfg
@@ -78,7 +78,7 @@ reject_spoofed = 0
 # refuses mail from user names commonly abused in that way.
 ;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]
 # namespace where SPF records can be supplied for domains without one
 # records are searched for under _spf.domain.com
diff --git a/spfmilter.cfg b/spfmilter.cfg
new file mode 100644
index 0000000..3a2a7c2
--- /dev/null
+++ b/spfmilter.cfg
@@ -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
diff --git a/spfmilter.py b/spfmilter.py
index b04b317..c3617df 100644
--- a/spfmilter.py
+++ b/spfmilter.py
@@ -13,26 +13,55 @@ import spf
 import struct
 import socket
 import syslog
-
+from Milter.config import MilterConfigParser
 from Milter.utils import iniplist,parse_addr
 
 syslog.openlog('spfmilter',0,syslog.LOG_MAIL)
 
-# list of trusted forwarder domains.  An SPF record for a forwarder
-# domain lists IP addresses from which forwarded mail is accepted.
-trusted_forwarder = []
-# 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"
-#socketname = os.getenv("HOME") + "/pythonsock"
-miltername = "pyspffilter"
-
+class Config(object):
+  "Hold configuration options."
+  pass
+
+def read_config(list):
+  "Return new config object."
+  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):
-  "Milter to check SPF."
+  "Milter to check SPF.  Each connection gets its own instance."
 
   def log(self,*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):
     self.mailfrom = None
     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
   # which can then be applied by alter_headers()
@@ -55,9 +86,9 @@ class spfMilter(Milter.Milter):
     self.receiver = self.getsymval('j').strip()
     if hostaddr and len(hostaddr) > 0:
       ipaddr = hostaddr[0]
-      if iniplist(ipaddr,internal_connect):
+      if iniplist(ipaddr,self.conf.internal_connect):
 	self.internal_connection = True
-      if iniplist(ipaddr,trusted_relay):
+      if iniplist(ipaddr,self.conf.trusted_relay):
         self.trusted_relay = True
     else: ipaddr = ''
     self.connectip = ipaddr
@@ -112,7 +143,7 @@ class spfMilter(Milter.Milter):
 
   def check_spf(self):
     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)
       res,code,txt = q.check()
       if res == 'pass':
@@ -141,25 +172,58 @@ class spfMilter(Milter.Milter):
       else:
         hres,hcode,htxt = res,code,txt
     else: hres = None
+
+    p = SPFPolicy(q.s)
+
     if res == 'fail':
-      self.log('REJECT: SPF %s %i %s' % (res,code,txt))
-      self.setreply(str(code),'5.7.1',txt)
-      # A proper SPF fail error message would read:
-      # forger.biz [1.2.3.4] is not allowed to send mail with the domain
-      # "forged.org" in the sender address.  Contact <postmaster@forged.org>.
-      return Milter.REJECT
-    if res == 'permerror':
-      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
-    if res == 'temperror':
-      self.log('TEMPFAIL: SPF %s %i %s' % (res,code,txt))
-      self.setreply(str(code),'4.3.0',txt)
-      return Milter.TEMPFAIL
+      policy = p.getPolicy('spf-fail:')
+      if not policy or policy == 'REJECT':
+	self.log('REJECT: SPF %s %i %s' % (res,code,txt))
+	self.setreply(str(code),'5.7.1',txt)
+	# A proper SPF fail error message would read:
+	# forger.biz [1.2.3.4] is not allowed to send mail with the domain
+	# "forged.org" in the sender address.  Contact <postmaster@forged.org>.
+	return Milter.REJECT
+    if res == 'softfail':
+      policy = p.getPolicy('spf-softfail:')
+      if policy and policy == 'REJECT':
+	self.log('REJECT: SPF %s %i %s' % (res,code,txt))
+	self.setreply(str(code),'5.7.1',txt)
+	# A proper SPF fail error message would read:
+	# forger.biz [1.2.3.4] is not allowed to send mail with the domain
+	# "forged.org" in the sender address.  Contact <postmaster@forged.org>.
+	return Milter.REJECT
+    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)
     if hres and q.h != q.o:
       self.add_header('X-Hello-SPF',hres,0)
@@ -168,6 +232,10 @@ class spfMilter(Milter.Milter):
 if __name__ == "__main__":
   Milter.factory = spfMilter
   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:
 
 O InputMailFilters=%s
-- 
GitLab