From ba626ea6b52f3d59da7192d0451888379b4c294f Mon Sep 17 00:00:00 2001 From: "Stuart D. Gathman" Date: Mon, 13 Nov 2017 21:37:06 -0500 Subject: [PATCH] Add service and test cases for srsmilter, fix some bugs, update sample config. --- SRS/DB.py | 7 +++-- envfrom2srs.py | 2 +- pysrs.cfg | 11 +++++++ pysrs.spec | 7 ++++- setup.py | 2 +- srs2envtol.py | 2 +- srsmilter.py | 77 ++++++++++++++++++++++++++++++----------------- srsmilter.service | 15 +++++++++ testSRS.py | 68 +++++++++++++++++++++++++++++++++++++++-- 9 files changed, 155 insertions(+), 36 deletions(-) create mode 100644 srsmilter.service diff --git a/SRS/DB.py b/SRS/DB.py index 57f7970..e2f7fa5 100644 --- a/SRS/DB.py +++ b/SRS/DB.py @@ -21,7 +21,10 @@ # This program is free software; you can redistribute it and/or modify # it under the same terms as Python itself. -import bsddb3 +try: + import bsddb3 as bsddb +except: + import bsddb import time import SRS from .Base import Base @@ -53,7 +56,7 @@ class DB(Base): def __init__(self,database='/var/run/srs.db',hashlength=24,*args,**kw): Base.__init__(self,hashlength=hashlength,*args,**kw) assert database, "No database specified for SRS.DB" - self.dbm = bsddb3.btopen(database,'c') + self.dbm = bsddb.btopen(database,'c') def compile(self,sendhost,senduser,srshost=None): ts = time.time() diff --git a/envfrom2srs.py b/envfrom2srs.py index 0cbed82..63e3d39 100644 --- a/envfrom2srs.py +++ b/envfrom2srs.py @@ -11,7 +11,7 @@ import SRS import re -from configparser import ConfigParser, DuplicateSectionError +from ConfigParser import ConfigParser, DuplicateSectionError # get SRS parameters from milter configuration cp = ConfigParser({ diff --git a/pysrs.cfg b/pysrs.cfg index 77d57b3..4904a19 100644 --- a/pysrs.cfg +++ b/pysrs.cfg @@ -15,3 +15,14 @@ ;srs = otherdomain.com # do not rewrite mail to these domains ;nosrs = braindeadmail.com +# +[srsmilter] +;datadir=/var/lib/milter +socketname = /var/run/milter/srsmilter +miltername = pysrsfilter +# reject DSNs to unsigned recipients (bounce spam) +reject_spoofed = true +;trusted_relay = 1.2.3.4 +internal_connect = 192.168.*.*,127.0.0.1,::1 +# Enable outgoing SRS via CHGFROM (see code for limitations) +miltersrs = false diff --git a/pysrs.spec b/pysrs.spec index d592c2d..30a7099 100644 --- a/pysrs.spec +++ b/pysrs.spec @@ -8,7 +8,7 @@ Summary: Python SRS (Sender Rewriting Scheme) library Name: %{pythonbase}-pysrs -Version: 1.0.2 +Version: 1.0.3 Release: 1%{?dist} Source0: pysrs-%{version}.tar.gz License: Python license @@ -73,9 +73,11 @@ cp pysrsprog.m4 $RPM_BUILD_ROOT/usr/share/sendmail-cf/hack mkdir -p $RPM_BUILD_ROOT/var/log/milter mkdir -p $RPM_BUILD_ROOT%{_libexecdir}/milter cp -p pysrs.py $RPM_BUILD_ROOT%{_libexecdir}/milter/pysrs +cp -p srsmilter.py $RPM_BUILD_ROOT%{_libexecdir}/milter/srsmilter %if %{use_systemd} mkdir -p $RPM_BUILD_ROOT%{_unitdir} cp -p pysrs.service $RPM_BUILD_ROOT%{_unitdir} +cp -p srsmilter.service $RPM_BUILD_ROOT%{_unitdir} %else mkdir -p $RPM_BUILD_ROOT/etc/rc.d/init.d cp %{sysvinit} $RPM_BUILD_ROOT/etc/rc.d/init.d/pysrs @@ -140,6 +142,9 @@ fi %endif %changelog +* Mon Nov 13 2017 Stuart Gathman 1.0.3-1 +- Include srsmilter + * Tue Nov 3 2017 Stuart Gathman 1.0.2-1 - Fix daemon to run in python2 - Move daemons to /usr/libexec/milter so they get bin_t selinux label diff --git a/setup.py b/setup.py index 00cc7d9..7a1b74d 100644 --- a/setup.py +++ b/setup.py @@ -19,7 +19,7 @@ #-- Package description name = 'pysrs', license = 'Python license', - version = '1.0.2', + version = '1.0.3', description = 'Python SRS (Sender Rewriting Scheme) library', long_description = """Python SRS (Sender Rewriting Scheme) library. As SPF is implemented, MTAs that check SPF must account for any forwarders. diff --git a/srs2envtol.py b/srs2envtol.py index 7f46a87..2766565 100644 --- a/srs2envtol.py +++ b/srs2envtol.py @@ -11,7 +11,7 @@ import SRS import re -from configparser import ConfigParser, DuplicateSectionError +from ConfigParser import ConfigParser, DuplicateSectionError # get SRS parameters from milter configuration cp = ConfigParser({ diff --git a/srsmilter.py b/srsmilter.py index 37aad93..183a289 100755 --- a/srsmilter.py +++ b/srsmilter.py @@ -1,7 +1,9 @@ +#!/usr/bin/python2 +# # A simple SRS milter for Sendmail-8.14/Postfix-? - # -# INCOMPLETE!! +# NOTE: use with pysrs socketmap and sendmail-cf macro to handle +# multiple recipients. # # The logical problem is that a milter gets to change MFROM only once for # multiple recipients. When there is a conflict between recipients, we @@ -12,8 +14,9 @@ # http://www.sendmail.org/doc/sendmail-current/libmilter/docs/installation.html -# Author: Stuart D. Gathman +# Author: Stuart D. Gathman # Copyright 2007 Business Management Systems, Inc. +# Copyright 2017 Stuart D. Gathman # This program is free software; you can redistribute it and/or modify # it under the same terms as Python itself. @@ -21,8 +24,8 @@ import SES import sys import Milter -import spf import syslog +import re from Milter.config import MilterConfigParser from Milter.utils import iniplist,parse_addr @@ -33,27 +36,29 @@ class Config(object): def __init__(conf,cfglist): cp = MilterConfigParser() cp.read(cfglist) - if cp.has_option('milter','datadir'): - os.chdir(cp.get('milter','datadir')) # FIXME: side effect! - conf.socketname = cp.getdefault('milter','socketname', - '/var/run/milter/pysrs') - conf.miltername = cp.getdefault('milter','name','pysrsfilter') - conf.trusted_relay = cp.getlist('milter','trusted_relay') - conf.internal_connect = cp.getlist('milter','internal_connect') + if cp.has_option('srsmilter','datadir'): + os.chdir(cp.get('srsmilter','datadir')) # FIXME: side effect! + conf.socketname = cp.getdefault('srsmilter','socketname', + '/var/run/milter/srsmilter') + conf.miltername = cp.getdefault('srsmilter','name','pysrsfilter') + conf.trusted_relay = cp.getlist('srsmilter','trusted_relay') + conf.miltersrs = cp.getboolean('srsmilter','miltersrs') + conf.internal_connect = cp.getlist('srsmilter','internal_connect') + conf.srs_reject_spoofed = cp.getboolean('srsmilter','reject_spoofed') conf.trusted_forwarder = cp.getlist('srs','trusted_forwarder') conf.secret = cp.getdefault('srs','secret','shhhh!') conf.maxage = cp.getintdefault('srs','maxage',21) conf.hashlength = cp.getintdefault('srs','hashlength',5) conf.separator = cp.getdefault('srs','separator','=') conf.database = cp.getdefault('srs','database') - conf.srs_reject_spoofed = cp.getboolean('srs','reject_spoofed') conf.nosrsdomain = cp.getlist('srs','nosrs') # no SRS rcpt conf.banned_users = cp.getlist('srs','banned_users') conf.srs_domain = set(cp.getlist('srs','srs')) # check rcpt conf.sesdomain = set(cp.getlist('srs','ses')) # sign from with ses conf.signdomain = set(cp.getlist('srs','sign')) # sign from with srs conf.fwdomain = cp.getdefault('srs','fwdomain',None) # forwarding domain - if database: + if conf.database: + global SRS import SRS.DB conf.srs = SRS.DB.DB(database=conf.database,secret=conf.secret, maxage=conf.maxage,hashlength=conf.hashlength,separator=conf.separator) @@ -167,12 +172,12 @@ def del_recipient(self,rcpt): self.discard_list.append(rcpt) ## Accumulate added recipients to be applied in eom callback. - def add_recipient(self,rcpt): + def add_recipient(self,rcpt,params): rcpt = rcpt.lower() - if not rcpt in self.redirect_list: - self.redirect_list.append(rcpt) + if not rcpt in (r[0] for r in self.redirect_list): + self.redirect_list.append((rcpt,params)) - def envrcpt(self,to,*str): + def envrcpt(self,to,*params): conf = self.conf t = parse_addr(to) if len(t) == 2: @@ -193,10 +198,11 @@ def envrcpt(self,to,*str): newaddr = srs.reverse(oldaddr) self.log("srs rcpt:",newaddr) self.del_recipient(to) - self.add_recipient('<%s>',newaddr) + self.add_recipient('<%s>',newaddr,params) except: # no valid SRS signature if not (self.internal_connection or self.trusted_relay): + # reject specific recipients with bad sig if self.srsre.match(oldaddr): self.log("REJECT: srs spoofed:",oldaddr) self.setreply('550','5.7.1','Invalid SRS signature') @@ -205,10 +211,11 @@ def envrcpt(self,to,*str): self.log("REJECT: ses spoofed:",oldaddr) self.setreply('550','5.7.1','Invalid SES signature') return Milter.REJECT + # reject message for any missing sig self.data_allowed = not conf.srs_reject_spoofed else: # sign "outgoing" from - if domain in nosrsdomain: + if domain in self.conf.nosrsdomain: self.nosrsrcpt.append(to) else: self.srsrcpt.append(to) @@ -216,19 +223,33 @@ def envrcpt(self,to,*str): self.nosrsrcpt.append(to) return Milter.CONTINUE + def eoh(self): + if not self.data_allowed: + return Milter.REJECT + return Milter.CONTINUE + def eom(self): - for name,val,idx in self.new_headers: - try: - self.addheader(name,val,idx) - except: - self.addheader(name,val) # older sendmail can't insheader + # apply recipient changes + for to in self.discard_list: + self.delrcpt(to) + for to,p in self.redirect_list: + self.addrcpt(to,p) + # optionally, do outgoing SRS for all recipients + if self.conf.miltersrs and self.srsrcpt: + newaddr = self.make_srs(self.canon_from) + if newaddr != self.canon_from: + self.chgfrom(newaddr) return Milter.CONTINUE if __name__ == "__main__": + global config + config = Config(['srsmilter.cfg','/etc/mail/srsmilter.cfg']) Milter.factory = srsMilter + if config.miltersrs: + flags = Milter.CHGFROM + Milter.DELRCPT + else: + flags = Milter.DELRCPT Milter.set_flags(Milter.CHGFROM + Milter.DELRCPT) - global config - config = 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: @@ -239,5 +260,5 @@ def eom(self): See the sendmail README for libmilter. sample srsmilter startup""" % (miltername,miltername,socketname)) sys.stdout.flush() - Milter.runmilter("pysrsfilter",socketname,240) - print("sample srsmilter shutdown") + Milter.runmilter(miltername,socketname,240) + print("srsmilter shutdown") diff --git a/srsmilter.service b/srsmilter.service new file mode 100644 index 0000000..3b5d809 --- /dev/null +++ b/srsmilter.service @@ -0,0 +1,15 @@ +[Unit] +Description=Python SRS milter +Wants=network.target +After=network-online.target sendmail.service + +[Service] +Type=simple +WorkingDirectory=/var/log/milter +User=mail +Group=mail +SyslogIdentifier=srsmilter +ExecStart=/usr/libexec/milter/srsmilter + +[Install] +WantedBy=multi-user.target diff --git a/testSRS.py b/testSRS.py index 57ed28e..ba6b78b 100644 --- a/testSRS.py +++ b/testSRS.py @@ -27,20 +27,80 @@ # Translated to Python by stuart@bmsi.com # http://bmsi.com/python/milter.html # +# Copyright (c) 2017 Stuart Gathman All rights reserved. # Portions Copyright (c) 2004 Shevek. All rights reserved. -# Portions Copyright (c) 2004 Business Management Systems. All rights reserved. +# Portions Copyright (c) 2004,2006 Business Management Systems. All rights reserved. # # This program is free software; you can redistribute it and/or modify # it under the same terms as Python itself. import unittest +import Milter +from Milter.test import TestBase from SRS.Guarded import Guarded from SRS.DB import DB from SRS.Reversible import Reversible from SRS.Daemon import Daemon +import srsmilter import SRS import threading import socket +try: + from StringIO import StringIO +except: + from io import StringIO + +class TestMilter(TestBase,srsmilter.srsMilter): + def __init__(self): + TestBase.__init__(self) + srsmilter.config = srsmilter.Config(['pysrs.cfg']) + srsmilter.srsMilter.__init__(self) + self.setsymval('j','test.milter.org') + +class SRSMilterTestCase(unittest.TestCase): + + msg = '''From: good@example.com +Subject: test + +test +''' + + ## Test rejecting bounce spam + def testReject(self): + milter = TestMilter() + milter.conf.srs_domain = set(['example.com']) + milter.conf.srs_reject_spoofed = False + fp = StringIO(self.msg) + rc = milter.connect('testReject',ip='192.0.3.1') + self.assertEqual(rc,Milter.CONTINUE) + rc = milter.feedFile(fp,sender='',rcpt='good@example.org') + self.assertEqual(rc,Milter.CONTINUE) + milter.conf.srs_reject_spoofed = True + fp.seek(0) + rc = milter.feedFile(fp,sender='',rcpt='bad@example.com') + self.assertEqual(rc,Milter.REJECT) + milter.close() + + ## Test SRS coding of MAIL FROM + def testSign(self): + milter = TestMilter() + milter.conf.signdomain = set(['example.com']) + milter.conf.miltersrs = True + fp = StringIO(self.msg) + rc = milter.connect('testSign',ip='192.0.3.1') + self.assertEqual(rc,Milter.CONTINUE) + fp.seek(0) + rc = milter.feedFile(fp,sender='good@example.com',rcpt='good@example.org') + self.assertEqual(rc,Milter.CONTINUE) + s = milter.conf.srs.reverse(milter._sender[1:-1]) + self.assertEqual(s,'good@example.com') + # check that it doesn't happen when disabled + milter.conf.miltersrs = False + fp.seek(0) + rc = milter.feedFile(fp,sender='good@example.com',rcpt='good@example.org') + self.assertEqual(rc,Milter.CONTINUE) + self.assertEqual(milter._sender,'') + milter.close() class SRSTestCase(unittest.TestCase): @@ -191,7 +251,11 @@ def testProgMap(self): self.assertEqual(addr2,orig) self.assertTrue(self.case_smashed) -def suite(): return unittest.makeSuite(SRSTestCase,'test') +def suite(): + s = unittest.makeSuite(SRSTestCase,'test') + s.addTest(makeSuite(SRSMilterTestCase,'test')) + #s.addTest(doctest.DocTestSuite(bms)) + return s if __name__ == '__main__': unittest.main()