From 4111454a57352d04378bdef916822893f8f6900c Mon Sep 17 00:00:00 2001 From: Dominik Chilla Date: Mon, 20 Jul 2020 00:09:33 +0200 Subject: [PATCH 1/6] 5321.from domains from LDAP --- .gitignore | 15 ++ README.md | 6 + activate_venv | 2 + app/sos-milter.py | 182 ++++++++++++++++------ app/sos-milter_eom.py | 322 +++++++++++++++++++++++++++++++++++++++ docker/debian/Dockerfile | 1 + tests/miltertest.lua | 47 ++++++ 7 files changed, 526 insertions(+), 49 deletions(-) create mode 100644 .gitignore create mode 100644 activate_venv create mode 100644 app/sos-milter_eom.py create mode 100644 tests/miltertest.lua diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0bd40ef --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +venv/ + +*.pyc +__pycache__/ + +instance/ + +.pytest_cache/ +.coverage +htmlcov/ + +dist/ +build/ +*.egg-info/ + diff --git a/README.md b/README.md index c291642..90220c6 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,12 @@ services: #MILTER_TMPFAIL_MESSAGE: Message temporary rejected. Please try again later ;) SPF_REGEX: '^.*include:secure-mailgate\.com.*$$' IGNORED_NEXT_HOPS: 'some-mailrelay.xyz:123, another.relay, and.so.on:125' + LDAP_ENABLED: 'some_value' + LDAP_SERVER_URI: 'ldaps://some.ldap.server' + LDAP_BINDDN: 'some-ldap-user-dn' + LDAP_BINDPW: 'some-secret-pw' + LDAP_SEARCH_BASE: 'ou=domains,dc=SLD,dc=TLD' + LDAP_QUERY_FILTER: '(dc=%d)' hostname: sos-milter volumes: - "sosm_socket:/socket/:rw" diff --git a/activate_venv b/activate_venv new file mode 100644 index 0000000..3fcc873 --- /dev/null +++ b/activate_venv @@ -0,0 +1,2 @@ +. venv/bin/activate + diff --git a/app/sos-milter.py b/app/sos-milter.py index 022dc3e..3c036ba 100644 --- a/app/sos-milter.py +++ b/app/sos-milter.py @@ -7,37 +7,42 @@ import string import random import re import dns.resolver -from timeit import default_timer as timer -import pprint +from ldap3 import ( + Server, Connection, NONE, ALL, set_config_parameter, ALL_ATTRIBUTES, + ALL_OPERATIONAL_ATTRIBUTES, MODIFY_REPLACE, HASHED_NONE, HASHED_SALTED_SHA, +) +from ldap3.core.exceptions import LDAPException # Globals with mostly senseless defaults ;) g_milter_name = 'sos-milter' g_milter_socket = '/socket/' + g_milter_name g_milter_reject_message = 'Security policy violation!' g_milter_tmpfail_message = 'Service temporarily not available! Please try again later.' -g_re_domain = re.compile(r'^\S*@(\S+)$', re.IGNORECASE) +g_re_domain = re.compile(r'^.*@(\S+)$', re.IGNORECASE) g_re_spf_regex = re.compile(r'.*', re.IGNORECASE) g_re_expected_txt_data = '' g_loglevel = logging.INFO g_milter_mode = 'test' g_ignored_next_hops = {} +g_ldap_conn = None +g_ldap_binddn = '' +g_ldap_bindpw = '' class SOSMilter(Milter.Base): # Each new connection is handled in an own thread def __init__(self): - self.time_start = timer() - self.null_sender = False + self.is_null_sender = False self.env_from = None self.env_from_domain = None + self.is_env_from_domain_in_ldap = False self.spf_record = None - self.add_header = False self.queue_id = None self.next_hop = None - # https://stackoverflow.com/a/2257449 self.mconn_id = g_milter_name + ': ' + ''.join( random.choice(string.ascii_lowercase + string.digits) for _ in range(8) ) + # Not registered/used callbacks @Milter.nocallback def connect(self, IPname, family, hostaddr): @@ -46,6 +51,9 @@ class SOSMilter(Milter.Base): def hello(self, heloname): return Milter.CONTINUE @Milter.nocallback + def data(self): + return Milter.CONTINUE + @Milter.nocallback def header(self, name, hval): return Milter.CONTINUE @Milter.nocallback @@ -59,7 +67,7 @@ class SOSMilter(Milter.Base): try: # DSNs/bounces are not relevant if(mailfrom == '<>'): - self.null_sender = True + self.is_null_sender = True return Milter.CONTINUE mailfrom = mailfrom.replace("<","") mailfrom = mailfrom.replace(">","") @@ -69,24 +77,41 @@ class SOSMilter(Milter.Base): logging.error(self.mconn_id + "/FROM " + "Could not determine domain of 5321.from=" + self.env_from ) - self.setreply('450','4.7.1', g_milter_tmpfail_message) - return Milter.TEMPFAIL + self.is_null_sender = True + return Milter.CONTINUE self.env_from_domain = m.group(1) logging.debug(self.mconn_id + - "/FROM 5321.from-domain=" + self.env_from_domain + "/FROM 5321_from_domain=" + self.env_from_domain ) + # Check if env_from_domain is in ldap + if(g_ldap_conn is not None): + filter = os.environ['LDAP_QUERY_FILTER'] + filter = filter.replace("%d", self.env_from_domain) + try: + g_ldap_conn.search(os.environ['LDAP_SEARCH_BASE'], + filter, + attributes=[ALL_ATTRIBUTES] + ) + if len(g_ldap_conn.entries) != 0: + self.is_env_from_domain_in_ldap = True + logging.info(self.mconn_id + + "/FROM Domain {0} found in LDAP".format(self.env_from_domain) + ) + except LDAPException: + logging.error(self.mconn_id + "/FROM " + traceback.format_exc()) + # Get TXT record of sender domain dns_response = None try: - dns_response = dns.resolver.query(self.env_from_domain, 'TXT') + dns_response = dns.resolver.resolve(self.env_from_domain, 'TXT') except dns.resolver.NoAnswer as e: - logging.error(self.mconn_id + + logging.warning(self.mconn_id + " /FROM " + e.msg ) # accept message if DNS-resolver fails return Milter.CONTINUE except dns.resolver.NXDOMAIN as e: - logging.error(self.mconn_id + + logging.warning(self.mconn_id + " /FROM " + e.msg ) # accept message if DNS-resolver fails @@ -96,10 +121,13 @@ class SOSMilter(Milter.Base): # accept message if DNS-resolver fails return Milter.CONTINUE for rdata in dns_response: + logging.debug(self.mconn_id + "/FROM DNS-TXT: {0}".format(rdata.to_text())) if re.match(r'^"v=spf1.*"$', rdata.to_text(), re.IGNORECASE): # we´ve got a SPF match! - self.spf_record = rdata.to_text() - break + self.spf_record = rdata.to_text() + # TODO: check if spf-record is a redirect!? + logging.debug(self.mconn_id + "/FROM Found SPFv1: {0}".format(self.spf_record)) + break return Milter.CONTINUE except: logging.error(self.mconn_id + " FROM-EXCEPTION: " + traceback.format_exc()) @@ -107,66 +135,87 @@ class SOSMilter(Milter.Base): return Milter.TEMPFAIL def envrcpt(self, to, *str): - if self.null_sender == True: + if self.is_null_sender == True: return Milter.CONTINUE - try: - self.next_hop = self.getsymval('{rcpt_host}') - except: - logging.error(self.mconn_id + "RCPT exception: " + traceback.format_exc()) - sys.exit(1) + self.next_hop = self.getsymval('{rcpt_host}') + if self.next_hop is None: + logging.error(self.mconn_id + "RCPT exception: could not retrieve milter-macro ({rcpt_host})") + else: + logging.debug(self.mconn_id + "/RCPT Next-Hop: {0}".format(self.next_hop)) return Milter.CONTINUE - def data(self): + # EOM is not optional and thus, always called by MTA + def eom(self): # A queue-id will be generated after the first accepted RCPT TO # and therefore not available until DATA command self.queue_id = self.getsymval('i') - if self.null_sender: + if self.queue_id is None: + logging.error(self.mconn_id + "EOM exception: could not retrieve milter-macro (i)!") + self.setreply('450','4.7.1', g_milter_tmpfail_message) + return Milter.TEMPFAIL + else: + logging.debug(self.mconn_id + "/EOM Queue-ID: {0}".format(self.queue_id)) + + if self.is_null_sender: logging.info(self.mconn_id + '/' + self.queue_id + - "/DATA Skipping bounce/DSN message" + "/EOM Skipping bounce/DSN message" ) return Milter.CONTINUE if self.spf_record is not None: - logging.info(self.mconn_id + '/' + self.queue_id + "/DATA " + - "SPFv1: " + self.spf_record + logging.info(self.mconn_id + '/' + self.queue_id + "/EOM " + + "SPFv1: " + str(self.spf_record) ) - logging.debug(self.mconn_id + '/' + self.queue_id + "/DATA " + - "next-hop=" + self.next_hop + logging.debug(self.mconn_id + '/' + self.queue_id + "/EOM " + + "next-hop=" + str(self.next_hop) ) if re.match(r'^".+-all"$', self.spf_record, re.IGNORECASE) is not None: # SPF record is in restrictive mode + logging.debug(self.mconn_id + '/' + self.queue_id + "/EOM " + + "SPF-record is signaling a FAIL-policy (-all)" + ) if g_re_spf_regex.match(self.spf_record) is not None: - logging.debug(self.mconn_id + '/' + self.queue_id + "/DATA" + - " SPF-record of 5321.from-domain=" + self.env_from_domain + + logging.debug(self.mconn_id + '/' + self.queue_id + "/EOM" + + " SPF-record of 5321_from_domain=" + self.env_from_domain + " permits us to relay this message" ) else: # Expected 'include' not found in SPF-record if self.next_hop in g_ignored_next_hops: - logging.info(self.mconn_id + '/' + self.queue_id + "/DATA " + + logging.info(self.mconn_id + '/' + self.queue_id + "/EOM " + "Passing message due to ignored next-hop=" + self.next_hop ) return Milter.CONTINUE - ex = "Restrictive SPF-record (-all) of 5321.from-domain=" + self.env_from_domain + " does not permit us to relay this message!" - if g_milter_mode == 'test': - logging.info(self.mconn_id + '/' + self.queue_id + "/DATA " + ex) - logging.debug(self.mconn_id + '/' + self.queue_id + "/DATA " + - 'test-mode: X-SOS-Milter header will be added. ' + if self.is_env_from_domain_in_ldap: + logging.info(self.mconn_id + '/' + self.queue_id + "/EOM " + + "5321_from_domain={0} (LDAP) has a broken SPF-record!".format(self.env_from_domain) ) - self.add_header = True + ex = str( + "SPF-record (-all) of 5321_from_domain=" + + self.env_from_domain + " does not permit us to relay this message!" + ) + if g_milter_mode == 'test': + logging.info(self.mconn_id + '/' + self.queue_id + "/EOM " + ex) + try: + self.addheader( + 'X-SOS-Milter', + self.mconn_id + ' ' + self.env_from_domain + ': failed SPF-expectation' + ) + logging.debug(self.mconn_id + '/' + self.queue_id + "/EOM " + + 'test-mode: X-SOS-Milter header was added. ' + ) + except: + logging.error(self.mconn_id + '/' + self.queue_id + "/EOM " + + "addheader() failed: " + traceback.format_exc() + ) else: - logging.error(self.mconn_id + '/' + self.queue_id + "/DATA " + ex) + logging.warning(self.mconn_id + '/' + self.queue_id + "/EOM " + ex) self.setreply('550','5.7.1', self.mconn_id + ' ' + ex + ' ' + g_milter_reject_message ) return Milter.REJECT - return Milter.CONTINUE - - def eom(self): - # EOM is not optional and thus, always called by MTA - if self.add_header == True: - self.addheader( - 'X-SOS-Milter', - self.mconn_id + ' ' + self.env_from_domain + ': failed SPF-expectation' + else: + logging.debug(self.mconn_id + '/' + self.queue_id + "/EOM " + + "No SPF-record found for {0}".format(self.env_from_domain) ) return Milter.CONTINUE @@ -216,10 +265,45 @@ if __name__ == "__main__": tmp_hops = os.environ['IGNORED_NEXT_HOPS'].split(',') for next_hop in tmp_hops: g_ignored_next_hops[next_hop] = 'ignore' - logging.error("next-hops: " + str(g_ignored_next_hops)) + logging.debug("next-hops: " + str(g_ignored_next_hops)) except: logging.error("ENV[IGNORED_NEXT_HOPS] exception: " + traceback.format_exc()) sys.exit(1) + if 'LDAP_ENABLED' in os.environ: + if 'LDAP_SERVER_URI' not in os.environ: + logging.error("ENV[LDAP_SERVER_URI] is mandatory!") + sys.exit(1) + if 'LDAP_BINDDN' not in os.environ: + logging.info("ENV[LDAP_BINDDN] not set! Continue...") + else: + g_ldap_binddn = os.environ['LDAP_BINDDN'] + if 'LDAP_BINDPW' not in os.environ: + logging.info("ENV[LDAP_BINDPW] not set! Continue...") + else: + g_ldap_bindpw = os.environ['LDAP_BINDPW'] + if 'LDAP_SEARCH_BASE' not in os.environ: + logging.error("ENV[LDAP_SEARCH_BASE] is mandatory!") + sys.exit(1) + if 'LDAP_QUERY_FILTER' not in os.environ: + logging.error("ENV[LDAP_QUERY_FILTER] is mandatory!") + sys.exit(1) + try: + set_config_parameter("RESTARTABLE_SLEEPTIME", 2) + set_config_parameter("RESTARTABLE_TRIES", True) + set_config_parameter('DEFAULT_SERVER_ENCODING', 'utf-8') + set_config_parameter('DEFAULT_CLIENT_ENCODING', 'utf-8') + server = Server(os.environ['LDAP_SERVER_URI'], get_info=NONE) + g_ldap_conn = Connection(server, + g_ldap_binddn, + g_ldap_bindpw, + auto_bind=True, + raise_exceptions=True, + client_strategy='RESTARTABLE' + ) + logging.info("LDAP-Connection steht. PID: " + str(os.getpid())) + except LDAPException as e: + print("LDAP-Exception: " + traceback.format_exc()) + sys.exit(1) try: timeout = 600 @@ -235,4 +319,4 @@ if __name__ == "__main__": Milter.runmilter(g_milter_name,g_milter_socket,timeout,True) logging.info("Shutdown " + g_milter_name) except: - logging.error("MAIN-EXCEPTION: " + traceback.format_exc()) + logging.error("MAIN-EXCEPTION: " + traceback.format_exc()) \ No newline at end of file diff --git a/app/sos-milter_eom.py b/app/sos-milter_eom.py new file mode 100644 index 0000000..a2c3c2a --- /dev/null +++ b/app/sos-milter_eom.py @@ -0,0 +1,322 @@ +import Milter +import sys +import traceback +import os +import logging +import string +import random +import re +import dns.resolver +from ldap3 import ( + Server, Connection, NONE, ALL, set_config_parameter, ALL_ATTRIBUTES, + ALL_OPERATIONAL_ATTRIBUTES, MODIFY_REPLACE, HASHED_NONE, HASHED_SALTED_SHA, +) +from ldap3.core.exceptions import LDAPException + +# Globals with mostly senseless defaults ;) +g_milter_name = 'sos-milter' +g_milter_socket = '/socket/' + g_milter_name +g_milter_reject_message = 'Security policy violation!' +g_milter_tmpfail_message = 'Service temporarily not available! Please try again later.' +g_re_domain = re.compile(r'^.*@(\S+)$', re.IGNORECASE) +g_re_spf_regex = re.compile(r'.*', re.IGNORECASE) +g_re_expected_txt_data = '' +g_loglevel = logging.INFO +g_milter_mode = 'test' +g_ignored_next_hops = {} +g_ldap_conn = None +g_ldap_binddn = '' +g_ldap_bindpw = '' + +class SOSMilter(Milter.Base): + # Each new connection is handled in an own thread + def __init__(self): + self.is_null_sender = False + self.env_from = None + self.env_from_domain = None + self.is_env_from_domain_in_ldap = False + self.spf_record = None + self.queue_id = None + self.next_hop = None + # https://stackoverflow.com/a/2257449 + self.mconn_id = g_milter_name + ': ' + ''.join( + random.choice(string.ascii_lowercase + string.digits) for _ in range(8) + ) + + # Not registered/used callbacks + @Milter.nocallback + def connect(self, IPname, family, hostaddr): + return Milter.CONTINUE + @Milter.nocallback + def hello(self, heloname): + return Milter.CONTINUE + @Milter.nocallback + def data(self): + return Milter.CONTINUE + @Milter.nocallback + def header(self, name, hval): + return Milter.CONTINUE + @Milter.nocallback + def eoh(self): + return Milter.CONTINUE + @Milter.nocallback + def body(self, chunk): + return Milter.CONTINUE + + def envfrom(self, mailfrom, *str): + try: + # DSNs/bounces are not relevant + if(mailfrom == '<>'): + self.is_null_sender = True + return Milter.CONTINUE + mailfrom = mailfrom.replace("<","") + mailfrom = mailfrom.replace(">","") + self.env_from = mailfrom + m = g_re_domain.match(self.env_from) + if m is None: + logging.error(self.mconn_id + "/FROM " + + "Could not determine domain of 5321.from=" + self.env_from + ) + self.is_null_sender = True + return Milter.CONTINUE + self.env_from_domain = m.group(1) + logging.debug(self.mconn_id + + "/FROM 5321_from_domain=" + self.env_from_domain + ) + # Check if env_from_domain is in ldap + if(g_ldap_conn is not None): + filter = os.environ['LDAP_QUERY_FILTER'] + filter = filter.replace("%d", self.env_from_domain) + try: + g_ldap_conn.search(os.environ['LDAP_SEARCH_BASE'], + filter, + attributes=[ALL_ATTRIBUTES] + ) + if len(g_ldap_conn.entries) != 0: + self.is_env_from_domain_in_ldap = True + logging.info(self.mconn_id + + "/FROM Domain {0} found in LDAP".format(self.env_from_domain) + ) + except LDAPException: + logging.error(self.mconn_id + "/FROM " + traceback.format_exc()) + + # Get TXT record of sender domain + dns_response = None + try: + dns_response = dns.resolver.resolve(self.env_from_domain, 'TXT') + except dns.resolver.NoAnswer as e: + logging.error(self.mconn_id + + " /FROM " + e.msg + ) + # accept message if DNS-resolver fails + return Milter.CONTINUE + except dns.resolver.NXDOMAIN as e: + logging.error(self.mconn_id + + " /FROM " + e.msg + ) + # accept message if DNS-resolver fails + return Milter.CONTINUE + except: + logging.error(self.mconn_id + " DNS-Resolver-EXCEPTION: " + traceback.format_exc()) + # accept message if DNS-resolver fails + return Milter.CONTINUE + for rdata in dns_response: + logging.debug(self.mconn_id + "/FROM DNS-TXT: {0}".format(rdata.to_text())) + if re.match(r'^"v=spf1.*"$', rdata.to_text(), re.IGNORECASE): + # we´ve got a SPF match! + self.spf_record = rdata.to_text() + # TODO: check if spf-record is a redirect!? + logging.debug(self.mconn_id + "/FROM Found SPFv1: {0}".format(self.spf_record)) + break + return Milter.CONTINUE + except: + logging.error(self.mconn_id + " FROM-EXCEPTION: " + traceback.format_exc()) + self.setreply('450','4.7.1', g_milter_tmpfail_message) + return Milter.TEMPFAIL + + def envrcpt(self, to, *str): + if self.is_null_sender == True: + return Milter.CONTINUE + self.next_hop = self.getsymval('{rcpt_host}') + if self.next_hop is None: + logging.error(self.mconn_id + "RCPT exception: could not retrieve milter-macro ({rcpt_host})") + else: + logging.debug(self.mconn_id + "/RCPT Next-Hop: {0}".format(self.next_hop)) + return Milter.CONTINUE + + # EOM is not optional and thus, always called by MTA + def eom(self): + # A queue-id will be generated after the first accepted RCPT TO + # and therefore not available until DATA command + self.queue_id = self.getsymval('i') + if self.queue_id is None: + logging.error(self.mconn_id + "EOM exception: could not retrieve milter-macro (i)!") + self.setreply('450','4.7.1', g_milter_tmpfail_message) + return Milter.TEMPFAIL + else: + logging.debug(self.mconn_id + "/EOM Queue-ID: {0}".format(self.queue_id)) + + if self.is_null_sender: + logging.info(self.mconn_id + '/' + self.queue_id + + "/EOM Skipping bounce/DSN message" + ) + return Milter.CONTINUE + if self.spf_record is not None: + logging.info(self.mconn_id + '/' + self.queue_id + "/EOM " + + "SPFv1: " + str(self.spf_record) + ) + logging.debug(self.mconn_id + '/' + self.queue_id + "/EOM " + + "next-hop=" + str(self.next_hop) + ) + if re.match(r'^".+-all"$', self.spf_record, re.IGNORECASE) is not None: + # SPF record is in restrictive mode + logging.debug(self.mconn_id + '/' + self.queue_id + "/EOM " + + "SPF-record is signaling a FAIL-policy (-all)" + ) + if g_re_spf_regex.match(self.spf_record) is not None: + logging.debug(self.mconn_id + '/' + self.queue_id + "/EOM" + + " SPF-record of 5321_from_domain=" + self.env_from_domain + + " permits us to relay this message" + ) + else: + # Expected 'include' not found in SPF-record + if self.next_hop in g_ignored_next_hops: + logging.info(self.mconn_id + '/' + self.queue_id + "/EOM " + + "Passing message due to ignored next-hop=" + self.next_hop + ) + return Milter.CONTINUE + if self.is_env_from_domain_in_ldap: + logging.info(self.mconn_id + '/' + self.queue_id + "/EOM " + + "5321_from_domain={0} (LDAP) has a broken SPF-record!".format(self.env_from_domain) + ) + ex = str( + "SPF-record (-all) of 5321_from_domain=" + + self.env_from_domain + " does not permit us to relay this message!" + ) + if g_milter_mode == 'test': + logging.info(self.mconn_id + '/' + self.queue_id + "/EOM " + ex) + try: + self.addheader( + 'X-SOS-Milter', + self.mconn_id + ' ' + self.env_from_domain + ': failed SPF-expectation' + ) + logging.debug(self.mconn_id + '/' + self.queue_id + "/EOM " + + 'test-mode: X-SOS-Milter header was added. ' + ) + except: + logging.error(self.mconn_id + '/' + self.queue_id + "/EOM " + + "addheader() failed: " + traceback.format_exc() + ) + else: + logging.warning(self.mconn_id + '/' + self.queue_id + "/EOM " + ex) + self.setreply('550','5.7.1', + self.mconn_id + ' ' + ex + ' ' + g_milter_reject_message + ) + return Milter.REJECT + else: + logging.debug(self.mconn_id + '/' + self.queue_id + "/EOM " + + "No SPF-record found for {0}".format(self.env_from_domain) + ) + return Milter.CONTINUE + + def abort(self): + # Client disconnected prematurely + return Milter.CONTINUE + + def close(self): + # Always called, even when abort is called. + # Clean up any external resources here. + return Milter.CONTINUE + +if __name__ == "__main__": + if 'LOG_LEVEL' in os.environ: + if re.match(r'^info$', os.environ['LOG_LEVEL'], re.IGNORECASE): + g_loglevel = logging.INFO + elif re.match(r'^warn|warning$', os.environ['LOG_LEVEL'], re.IGNORECASE): + g_loglevel = logging.WARN + elif re.match(r'^error$', os.environ['LOG_LEVEL'], re.IGNORECASE): + g_loglevel = logging.ERROR + elif re.match(r'debug', os.environ['LOG_LEVEL'], re.IGNORECASE): + g_loglevel = logging.DEBUG + logging.basicConfig( + filename=None, # log to stdout + format='%(asctime)s: %(levelname)s %(message)s', + level=g_loglevel + ) + if 'MILTER_MODE' in os.environ: + if re.match(r'^test|reject$',os.environ['MILTER_MODE'], re.IGNORECASE): + g_milter_mode = os.environ['MILTER_MODE'] + if 'MILTER_NAME' in os.environ: + g_milter_name = os.environ['MILTER_NAME'] + if 'MILTER_SOCKET' in os.environ: + g_milter_socket = os.environ['MILTER_SOCKET'] + if 'MILTER_REJECT_MESSAGE' in os.environ: + g_milter_reject_message = os.environ['MILTER_REJECT_MESSAGE'] + if 'MILTER_TMPFAIL_MESSAGE' in os.environ: + g_milter_tmpfail_message = os.environ['MILTER_TMPFAIL_MESSAGE'] + if 'SPF_REGEX' in os.environ: + try: + g_re_spf_regex = re.compile(os.environ['SPF_REGEX'], re.IGNORECASE) + except: + logging.error("ENV[SPF_REGEX] exception: " + traceback.format_exc()) + sys.exit(1) + if 'IGNORED_NEXT_HOPS' in os.environ: + try: + tmp_hops = os.environ['IGNORED_NEXT_HOPS'].split(',') + for next_hop in tmp_hops: + g_ignored_next_hops[next_hop] = 'ignore' + logging.debug("next-hops: " + str(g_ignored_next_hops)) + except: + logging.error("ENV[IGNORED_NEXT_HOPS] exception: " + traceback.format_exc()) + sys.exit(1) + if 'LDAP_ENABLED' in os.environ: + if 'LDAP_SERVER_URI' not in os.environ: + logging.error("ENV[LDAP_SERVER_URI] is mandatory!") + sys.exit(1) + if 'LDAP_BINDDN' not in os.environ: + logging.info("ENV[LDAP_BINDDN] not set! Continue...") + else: + g_ldap_binddn = os.environ['LDAP_BINDDN'] + if 'LDAP_BINDPW' not in os.environ: + logging.info("ENV[LDAP_BINDPW] not set! Continue...") + else: + g_ldap_bindpw = os.environ['LDAP_BINDPW'] + if 'LDAP_SEARCH_BASE' not in os.environ: + logging.error("ENV[LDAP_SEARCH_BASE] is mandatory!") + sys.exit(1) + if 'LDAP_QUERY_FILTER' not in os.environ: + logging.error("ENV[LDAP_QUERY_FILTER] is mandatory!") + sys.exit(1) + try: + set_config_parameter("RESTARTABLE_SLEEPTIME", 2) + set_config_parameter("RESTARTABLE_TRIES", True) + set_config_parameter('DEFAULT_SERVER_ENCODING', 'utf-8') + set_config_parameter('DEFAULT_CLIENT_ENCODING', 'utf-8') + server = Server(os.environ['LDAP_SERVER_URI'], get_info=NONE) + g_ldap_conn = Connection(server, + g_ldap_binddn, + g_ldap_bindpw, + auto_bind=True, + raise_exceptions=True, + client_strategy='RESTARTABLE' + ) + logging.info("LDAP-Connection steht. PID: " + str(os.getpid())) + except LDAPException as e: + print("LDAP-Exception: " + traceback.format_exc()) + sys.exit(1) + + try: + timeout = 600 + # Register to have the Milter factory create instances of your class: + Milter.factory = SOSMilter + # Tell the MTA which features we use + flags = Milter.ADDHDRS + Milter.set_flags(flags) + logging.info("Startup " + g_milter_name + + "@socket: " + g_milter_socket + + " in mode: " + g_milter_mode + ) + Milter.runmilter(g_milter_name,g_milter_socket,timeout,True) + logging.info("Shutdown " + g_milter_name) + except: + logging.error("MAIN-EXCEPTION: " + traceback.format_exc()) \ No newline at end of file diff --git a/docker/debian/Dockerfile b/docker/debian/Dockerfile index 0c0beab..afbd7c9 100644 --- a/docker/debian/Dockerfile +++ b/docker/debian/Dockerfile @@ -15,6 +15,7 @@ RUN env; set -ex ; \ gcc python3-dev \ && /usr/bin/pip3 install pymilter \ && /usr/bin/pip3 install dnspython \ + && /usr/bin/pip3 install ldap3 \ && /bin/mkdir /config /socket /app \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* diff --git a/tests/miltertest.lua b/tests/miltertest.lua new file mode 100644 index 0000000..6e88002 --- /dev/null +++ b/tests/miltertest.lua @@ -0,0 +1,47 @@ +-- https://mopano.github.io/sendmail-filter-api/constant-values.html#com.sendmail.milter.MilterConstants +-- http://www.opendkim.org/miltertest.8.html + +--conn = mt.connect("inet:8020@10.42.50.2") +conn = mt.connect("inet:12345@127.0.0.1") +if conn == nil then + error "mt.connect() failed" +end + +mt.set_timeout(3) + +-- 5321.FROM + MACROS +mt.macro(conn, SMFIC_MAIL, "i", "test-id",'{rcpt_host}', "test.next-hostx") +if mt.mailfrom(conn, "dominik@zwackl.def") ~= nil then + error "mt.mailfrom() failed" +end +if mt.getreply(conn) ~= SMFIR_CONTINUE then + error "mt.mailfrom() unexpected reply" +end + +-- 5321.RCPT +if mt.rcptto(conn, "info@dc-it-con.de") ~= nil then + error "mt.rcptto() failed" +end +if mt.getreply(conn) ~= SMFIR_CONTINUE then + error "mt.rcptto() unexpected reply" +end + +-- EOM +if mt.eom(conn) ~= nil then + error "mt.eom() failed" +end +mt.echo("EOM: " .. mt.getreply(conn)) +if mt.getreply(conn) == SMFIR_CONTINUE then + mt.echo("EOM-continue") +elseif mt.getreply(conn) == SMFIR_REPLYCODE then + mt.echo("EOM-reject") +end + +if not mt.eom_check(conn, MT_HDRADD, "X-SOS-Milter") then + mt.echo("no header added") +else + mt.echo("X-SOS-Milter header added -> test-mode") +end + +-- DISCONNECT +mt.disconnect(conn) \ No newline at end of file From 799e739df4456ba0378f5a1cd6259a0fff631b81 Mon Sep 17 00:00:00 2001 From: Dominik Chilla Date: Mon, 20 Jul 2020 13:42:53 +0200 Subject: [PATCH 2/6] docs --- README.md | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 90220c6..75ffa24 100644 --- a/README.md +++ b/README.md @@ -18,20 +18,35 @@ services: image: "sos-milter/debian:19.06_master" restart: unless-stopped environment: + # default: info, possible: info, warning, error, debug LOG_LEVEL: debug # default: test, possible: test,reject MILTER_MODE: reject + # Default: sos-milter MILTER_NAME: sos-milter - #MILTER_SOCKET: inet6:8020 - #MILTER_REJECT_MESSAGE: Message rejected due to security policy violation! - #MILTER_TMPFAIL_MESSAGE: Message temporary rejected. Please try again later ;) + # Default socket /socket/${MILTER_NAME} + # MILTER_SOCKET: inet6:8020 + # MILTER_REJECT_MESSAGE: Message rejected due to security policy violation! + # MILTER_TMPFAIL_MESSAGE: Message temporary rejected. Please try again later ;) + + # Expected Content of the spf-record, like a specific include + # docker-compose pitfall: Dollar-sign ($) must be escaped as $$ SPF_REGEX: '^.*include:secure-mailgate\.com.*$$' + + # If next-hop relay is one of the following, message will be ignored IGNORED_NEXT_HOPS: 'some-mailrelay.xyz:123, another.relay, and.so.on:125' + + # Search for sender domain in LDAP. Can be used to mark (add header) + # and identify (log) internal sender domains with broken SPF-records + # for further processing (log report or header-based routing). + # After a message was marked with an additional header, it can be + # routed other than usual (e.g. through a bounce-/fwd-relay) LDAP_ENABLED: 'some_value' LDAP_SERVER_URI: 'ldaps://some.ldap.server' LDAP_BINDDN: 'some-ldap-user-dn' LDAP_BINDPW: 'some-secret-pw' LDAP_SEARCH_BASE: 'ou=domains,dc=SLD,dc=TLD' + # %d will be replaced by recognized 5321.env_from_domain LDAP_QUERY_FILTER: '(dc=%d)' hostname: sos-milter volumes: From 6656e7b81a6456cc18e390d62b5ff77ec6897ba3 Mon Sep 17 00:00:00 2001 From: Dominik Chilla Date: Mon, 20 Jul 2020 16:54:32 +0200 Subject: [PATCH 3/6] add_header if LDAP-Domain with broken SPF-record --- .vscode/settings.json | 4 ++++ app/sos-milter.py | 21 +++++++++------------ tests/miltertest.lua | 4 ++-- 3 files changed, 15 insertions(+), 14 deletions(-) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..cc67606 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "python.linting.pylintEnabled": true, + "python.linting.enabled": true +} \ No newline at end of file diff --git a/app/sos-milter.py b/app/sos-milter.py index 3c036ba..2ee003f 100644 --- a/app/sos-milter.py +++ b/app/sos-milter.py @@ -189,17 +189,8 @@ class SOSMilter(Milter.Base): logging.info(self.mconn_id + '/' + self.queue_id + "/EOM " + "5321_from_domain={0} (LDAP) has a broken SPF-record!".format(self.env_from_domain) ) - ex = str( - "SPF-record (-all) of 5321_from_domain=" - + self.env_from_domain + " does not permit us to relay this message!" - ) - if g_milter_mode == 'test': - logging.info(self.mconn_id + '/' + self.queue_id + "/EOM " + ex) try: - self.addheader( - 'X-SOS-Milter', - self.mconn_id + ' ' + self.env_from_domain + ': failed SPF-expectation' - ) + self.addheader('X-SOS-Milter', 'failed SPF-expectation') logging.debug(self.mconn_id + '/' + self.queue_id + "/EOM " + 'test-mode: X-SOS-Milter header was added. ' ) @@ -207,8 +198,14 @@ class SOSMilter(Milter.Base): logging.error(self.mconn_id + '/' + self.queue_id + "/EOM " + "addheader() failed: " + traceback.format_exc() ) - else: - logging.warning(self.mconn_id + '/' + self.queue_id + "/EOM " + ex) + ex = str( + " SPF-record (-all) of 5321_from_domain=" + + self.env_from_domain + " does not permit us to relay this message!" + ) + logging.info(self.mconn_id + '/' + self.queue_id + "/EOM " + + "mode=" + g_milter_mode + ' ' + ex + ) + if g_milter_mode == 'reject': self.setreply('550','5.7.1', self.mconn_id + ' ' + ex + ' ' + g_milter_reject_message ) diff --git a/tests/miltertest.lua b/tests/miltertest.lua index 6e88002..cbf3c06 100644 --- a/tests/miltertest.lua +++ b/tests/miltertest.lua @@ -11,7 +11,7 @@ mt.set_timeout(3) -- 5321.FROM + MACROS mt.macro(conn, SMFIC_MAIL, "i", "test-id",'{rcpt_host}', "test.next-hostx") -if mt.mailfrom(conn, "dominik@zwackl.def") ~= nil then +if mt.mailfrom(conn, "dominik@dc-it-con.de") ~= nil then error "mt.mailfrom() failed" end if mt.getreply(conn) ~= SMFIR_CONTINUE then @@ -40,7 +40,7 @@ end if not mt.eom_check(conn, MT_HDRADD, "X-SOS-Milter") then mt.echo("no header added") else - mt.echo("X-SOS-Milter header added -> test-mode") + mt.echo("X-SOS-Milter header added -> LDAP-Domain with broken SPF") end -- DISCONNECT From 7bfc3884d80743a619ed536242960828157d90cb Mon Sep 17 00:00:00 2001 From: Dominik Chilla Date: Sun, 9 Aug 2020 23:28:53 +0200 Subject: [PATCH 4/6] connection reusing; testing docs --- app/sos-milter.py | 35 +++++++++++++++++++++++++---------- tests/README.md | 23 +++++++++++++++++++++++ tests/miltertest.lua | 41 ++++++++++++++++++++++++++++++++++++++--- 3 files changed, 86 insertions(+), 13 deletions(-) create mode 100644 tests/README.md diff --git a/app/sos-milter.py b/app/sos-milter.py index 2ee003f..16a1f37 100644 --- a/app/sos-milter.py +++ b/app/sos-milter.py @@ -8,8 +8,7 @@ import random import re import dns.resolver from ldap3 import ( - Server, Connection, NONE, ALL, set_config_parameter, ALL_ATTRIBUTES, - ALL_OPERATIONAL_ATTRIBUTES, MODIFY_REPLACE, HASHED_NONE, HASHED_SALTED_SHA, + Server, Connection, NONE, set_config_parameter ) from ldap3.core.exceptions import LDAPException @@ -20,7 +19,6 @@ g_milter_reject_message = 'Security policy violation!' g_milter_tmpfail_message = 'Service temporarily not available! Please try again later.' g_re_domain = re.compile(r'^.*@(\S+)$', re.IGNORECASE) g_re_spf_regex = re.compile(r'.*', re.IGNORECASE) -g_re_expected_txt_data = '' g_loglevel = logging.INFO g_milter_mode = 'test' g_ignored_next_hops = {} @@ -31,6 +29,10 @@ g_ldap_bindpw = '' class SOSMilter(Milter.Base): # Each new connection is handled in an own thread def __init__(self): + self.reset() + + def reset(self): + self.client_ip = None self.is_null_sender = False self.env_from = None self.env_from_domain = None @@ -42,6 +44,7 @@ class SOSMilter(Milter.Base): self.mconn_id = g_milter_name + ': ' + ''.join( random.choice(string.ascii_lowercase + string.digits) for _ in range(8) ) + logging.debug(self.mconn_id + " RESET") # Not registered/used callbacks @Milter.nocallback @@ -64,6 +67,18 @@ class SOSMilter(Milter.Base): return Milter.CONTINUE def envfrom(self, mailfrom, *str): + # Instance member values remain within reused SMTP-connections! + if self.client_ip is not None: + # Milter connection reused! + logging.debug(self.mconn_id + "/FROM connection reused!") + self.reset() + self.client_ip = self.getsymval('{client_addr}') + if self.client_ip is None: + logging.error(self.mconn_id + " FROM exception: could not retrieve milter-macro ({client_addr})!") + self.setreply('450','4.7.1', g_milter_tmpfail_message) + return Milter.TEMPFAIL + else: + logging.debug(self.mconn_id + "/FROM client_ip={0}".format(self.client_ip)) try: # DSNs/bounces are not relevant if(mailfrom == '<>'): @@ -90,7 +105,7 @@ class SOSMilter(Milter.Base): try: g_ldap_conn.search(os.environ['LDAP_SEARCH_BASE'], filter, - attributes=[ALL_ATTRIBUTES] + attributes=[] ) if len(g_ldap_conn.entries) != 0: self.is_env_from_domain_in_ldap = True @@ -105,9 +120,7 @@ class SOSMilter(Milter.Base): try: dns_response = dns.resolver.resolve(self.env_from_domain, 'TXT') except dns.resolver.NoAnswer as e: - logging.warning(self.mconn_id + - " /FROM " + e.msg - ) + logging.warning(self.mconn_id + " /FROM " + e.msg) # accept message if DNS-resolver fails return Milter.CONTINUE except dns.resolver.NXDOMAIN as e: @@ -185,7 +198,7 @@ class SOSMilter(Milter.Base): "Passing message due to ignored next-hop=" + self.next_hop ) return Milter.CONTINUE - if self.is_env_from_domain_in_ldap: + if self.is_env_from_domain_in_ldap and g_milter_mode != 'reject': logging.info(self.mconn_id + '/' + self.queue_id + "/EOM " + "5321_from_domain={0} (LDAP) has a broken SPF-record!".format(self.env_from_domain) ) @@ -199,11 +212,11 @@ class SOSMilter(Milter.Base): "addheader() failed: " + traceback.format_exc() ) ex = str( - " SPF-record (-all) of 5321_from_domain=" + "SPF-record (-all) of 5321_from_domain=" + self.env_from_domain + " does not permit us to relay this message!" ) logging.info(self.mconn_id + '/' + self.queue_id + "/EOM " + - "mode=" + g_milter_mode + ' ' + ex + "mode=" + g_milter_mode + ' client=' + self.client_ip + ' ' + ex ) if g_milter_mode == 'reject': self.setreply('550','5.7.1', @@ -218,11 +231,13 @@ class SOSMilter(Milter.Base): def abort(self): # Client disconnected prematurely + logging.debug(self.mconn_id + "/ABORT") return Milter.CONTINUE def close(self): # Always called, even when abort is called. # Clean up any external resources here. + logging.debug(self.mconn_id + "/CLOSE") return Milter.CONTINUE if __name__ == "__main__": diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..865559c --- /dev/null +++ b/tests/README.md @@ -0,0 +1,23 @@ +# prepare testing env +``` +export TLD=de +export SLD=domain +export MILTER_MODE=reject +export MILTER_SOCKET=inet:12345 +export LOG_LEVEL=debug +export SPF_REGEX="^.*include:_spf\.blah\.blub.*$" +export LDAP_ENABLED=yepp +export LDAP_SERVER_URI="ldap://ldap-master-staging.int.${SLD}.${TLD}" +export LDAP_SEARCH_BASE="ou=domains,dc=${SLD},dc=${TLD}" +export LDAP_QUERY_FILTER='(dc=%d)' +export IGNORED_NEXT_HOPS=test.next-host +``` + +# start milter +`python3 app/sos-milter.py` + +# execute `miltertest` +First of all install the `miltertest` binary. Under debian based distros +it´s located in the `opendkim-tools` package. + +`miltertest -v -D socket=inet:12345@127.0.0.111 -s tests/miltertest.lua` \ No newline at end of file diff --git a/tests/miltertest.lua b/tests/miltertest.lua index cbf3c06..b579a7b 100644 --- a/tests/miltertest.lua +++ b/tests/miltertest.lua @@ -1,8 +1,8 @@ -- https://mopano.github.io/sendmail-filter-api/constant-values.html#com.sendmail.milter.MilterConstants -- http://www.opendkim.org/miltertest.8.html ---conn = mt.connect("inet:8020@10.42.50.2") -conn = mt.connect("inet:12345@127.0.0.1") +-- socket must be defined as miltertest global variable (-D) +conn = mt.connect(socket) if conn == nil then error "mt.connect() failed" end @@ -10,7 +10,42 @@ end mt.set_timeout(3) -- 5321.FROM + MACROS -mt.macro(conn, SMFIC_MAIL, "i", "test-id",'{rcpt_host}', "test.next-hostx") +mt.macro(conn, SMFIC_MAIL, '{client_addr}', "127.128.129.130", "i", "TestQueueId",'{rcpt_host}', "test.next-host") +if mt.mailfrom(conn, "dominik@dc-it-con.de") ~= nil then + error "mt.mailfrom() failed" +end +if mt.getreply(conn) ~= SMFIR_CONTINUE then + error "mt.mailfrom() unexpected reply" +end + +-- 5321.RCPT +if mt.rcptto(conn, "info@dc-it-con.de") ~= nil then + error "mt.rcptto() failed" +end +if mt.getreply(conn) ~= SMFIR_CONTINUE then + error "mt.rcptto() unexpected reply" +end + +-- EOM +if mt.eom(conn) ~= nil then + error "mt.eom() failed" +end +mt.echo("EOM: " .. mt.getreply(conn)) +if mt.getreply(conn) == SMFIR_CONTINUE then + mt.echo("EOM-continue") +elseif mt.getreply(conn) == SMFIR_REPLYCODE then + mt.echo("EOM-reject") +end + +if not mt.eom_check(conn, MT_HDRADD, "X-SOS-Milter") then + mt.echo("no header added") +else + mt.echo("X-SOS-Milter header added -> LDAP-Domain with broken SPF") +end + +--asdf +-- 5321.FROM + MACROS +mt.macro(conn, SMFIC_MAIL, '{client_addr}', "127.128.129.130", "i", "TestQueueId",'{rcpt_host}', "test.next-hostx") if mt.mailfrom(conn, "dominik@dc-it-con.de") ~= nil then error "mt.mailfrom() failed" end From fd77e8f93b618456ae5d60af1649188cf622e998 Mon Sep 17 00:00:00 2001 From: Dominik Chilla Date: Wed, 7 Jun 2023 21:08:21 +0200 Subject: [PATCH 5/6] SPF-redirect + refactoring + OCI@alpine --- BASEOS | 1 - OCI/Dockerfile | 26 +++ entrypoint.sh => OCI/cmd | 0 VERSION | 1 - activate_venv | 2 - app/sos-milter.py | 111 +++++++---- app/sos-milter_eom.py | 322 -------------------------------- docker-build.sh | 28 --- docker/debian/Dockerfile | 26 --- requirements.txt | 3 + tests/miltertest.lua | 44 +---- tests/miltertest_conn_reuse.lua | 85 +++++++++ tests/miltertest_redirect.lua | 49 +++++ 13 files changed, 240 insertions(+), 458 deletions(-) delete mode 100644 BASEOS create mode 100644 OCI/Dockerfile rename entrypoint.sh => OCI/cmd (100%) mode change 100755 => 100644 delete mode 100644 VERSION delete mode 100644 activate_venv delete mode 100644 app/sos-milter_eom.py delete mode 100755 docker-build.sh delete mode 100644 docker/debian/Dockerfile create mode 100644 requirements.txt create mode 100644 tests/miltertest_conn_reuse.lua create mode 100644 tests/miltertest_redirect.lua diff --git a/BASEOS b/BASEOS deleted file mode 100644 index 2dee175..0000000 --- a/BASEOS +++ /dev/null @@ -1 +0,0 @@ -debian diff --git a/OCI/Dockerfile b/OCI/Dockerfile new file mode 100644 index 0000000..0f35c47 --- /dev/null +++ b/OCI/Dockerfile @@ -0,0 +1,26 @@ +ARG PARENT_IMAGE=alpine:3.17 +FROM ${PARENT_IMAGE} +LABEL maintainer="Dominik Chilla " +LABEL git_repo="https://github.com/chillout2k/sos-milter" + +ADD ./requirements.txt /requirements.txt + +RUN apk update \ + && apk add --no-cache python3 python3-dev py3-pip \ + gcc libc-dev libmilter-dev \ + && pip3 install -r requirements.txt \ + && apk del gcc libc-dev libmilter-dev python3-dev py3-pip \ + && apk add libmilter \ + && adduser -D sos-milter \ + && install -d -o sos-milter /socket \ + && rm -rf /var/cache/apk/* /requirements.txt + +ADD ./app/ /app/ +ADD ./OCI/cmd /cmd +RUN chown -R sos-milter /app /cmd \ + && chmod -R +x /app /cmd + +VOLUME [ "/socket" ] + +USER sos-milter +CMD [ "/cmd" ] \ No newline at end of file diff --git a/entrypoint.sh b/OCI/cmd old mode 100755 new mode 100644 similarity index 100% rename from entrypoint.sh rename to OCI/cmd diff --git a/VERSION b/VERSION deleted file mode 100644 index f56a066..0000000 --- a/VERSION +++ /dev/null @@ -1 +0,0 @@ -20.02 diff --git a/activate_venv b/activate_venv deleted file mode 100644 index 3fcc873..0000000 --- a/activate_venv +++ /dev/null @@ -1,2 +0,0 @@ -. venv/bin/activate - diff --git a/app/sos-milter.py b/app/sos-milter.py index 16a1f37..740a3aa 100644 --- a/app/sos-milter.py +++ b/app/sos-milter.py @@ -115,35 +115,13 @@ class SOSMilter(Milter.Base): except LDAPException: logging.error(self.mconn_id + "/FROM " + traceback.format_exc()) - # Get TXT record of sender domain - dns_response = None - try: - dns_response = dns.resolver.resolve(self.env_from_domain, 'TXT') - except dns.resolver.NoAnswer as e: - logging.warning(self.mconn_id + " /FROM " + e.msg) - # accept message if DNS-resolver fails - return Milter.CONTINUE - except dns.resolver.NXDOMAIN as e: - logging.warning(self.mconn_id + - " /FROM " + e.msg - ) - # accept message if DNS-resolver fails - return Milter.CONTINUE - except: - logging.error(self.mconn_id + " DNS-Resolver-EXCEPTION: " + traceback.format_exc()) - # accept message if DNS-resolver fails - return Milter.CONTINUE - for rdata in dns_response: - logging.debug(self.mconn_id + "/FROM DNS-TXT: {0}".format(rdata.to_text())) - if re.match(r'^"v=spf1.*"$', rdata.to_text(), re.IGNORECASE): - # we´ve got a SPF match! - self.spf_record = rdata.to_text() - # TODO: check if spf-record is a redirect!? - logging.debug(self.mconn_id + "/FROM Found SPFv1: {0}".format(self.spf_record)) - break + # Get TXT-SPF record of sender domain + self.spf_record = self.get_spf_record(self.env_from_domain) return Milter.CONTINUE except: - logging.error(self.mconn_id + " FROM-EXCEPTION: " + traceback.format_exc()) + logging.error(self.mconn_id + + " FROM-EXCEPTION: " + traceback.format_exc() + ) self.setreply('450','4.7.1', g_milter_tmpfail_message) return Milter.TEMPFAIL @@ -152,9 +130,13 @@ class SOSMilter(Milter.Base): return Milter.CONTINUE self.next_hop = self.getsymval('{rcpt_host}') if self.next_hop is None: - logging.error(self.mconn_id + "RCPT exception: could not retrieve milter-macro ({rcpt_host})") + logging.error(self.mconn_id + + "RCPT exception: could not retrieve milter-macro ({rcpt_host})" + ) else: - logging.debug(self.mconn_id + "/RCPT Next-Hop: {0}".format(self.next_hop)) + logging.debug(self.mconn_id + + "/RCPT Next-Hop: {0}".format(self.next_hop) + ) return Milter.CONTINUE # EOM is not optional and thus, always called by MTA @@ -163,11 +145,15 @@ class SOSMilter(Milter.Base): # and therefore not available until DATA command self.queue_id = self.getsymval('i') if self.queue_id is None: - logging.error(self.mconn_id + "EOM exception: could not retrieve milter-macro (i)!") + logging.error(self.mconn_id + + "EOM exception: could not retrieve milter-macro (i)!" + ) self.setreply('450','4.7.1', g_milter_tmpfail_message) return Milter.TEMPFAIL else: - logging.debug(self.mconn_id + "/EOM Queue-ID: {0}".format(self.queue_id)) + logging.debug(self.mconn_id + + "/EOM Queue-ID: {0}".format(self.queue_id) + ) if self.is_null_sender: logging.info(self.mconn_id + '/' + self.queue_id + @@ -175,10 +161,12 @@ class SOSMilter(Milter.Base): ) return Milter.CONTINUE if self.spf_record is not None: - logging.info(self.mconn_id + '/' + self.queue_id + "/EOM " + + logging.info(self.mconn_id + + '/' + self.queue_id + "/EOM " + "SPFv1: " + str(self.spf_record) ) - logging.debug(self.mconn_id + '/' + self.queue_id + "/EOM " + + logging.debug(self.mconn_id + + '/' + self.queue_id + "/EOM " + "next-hop=" + str(self.next_hop) ) if re.match(r'^".+-all"$', self.spf_record, re.IGNORECASE) is not None: @@ -194,21 +182,25 @@ class SOSMilter(Milter.Base): else: # Expected 'include' not found in SPF-record if self.next_hop in g_ignored_next_hops: - logging.info(self.mconn_id + '/' + self.queue_id + "/EOM " + + logging.info(self.mconn_id + + '/' + self.queue_id + "/EOM " + "Passing message due to ignored next-hop=" + self.next_hop ) return Milter.CONTINUE if self.is_env_from_domain_in_ldap and g_milter_mode != 'reject': - logging.info(self.mconn_id + '/' + self.queue_id + "/EOM " + + logging.info(self.mconn_id + + '/' + self.queue_id + "/EOM " + "5321_from_domain={0} (LDAP) has a broken SPF-record!".format(self.env_from_domain) ) try: self.addheader('X-SOS-Milter', 'failed SPF-expectation') - logging.debug(self.mconn_id + '/' + self.queue_id + "/EOM " + + logging.debug(self.mconn_id + '/' + + self.queue_id + "/EOM " + 'test-mode: X-SOS-Milter header was added. ' ) except: - logging.error(self.mconn_id + '/' + self.queue_id + "/EOM " + + logging.error(self.mconn_id + + '/' + self.queue_id + "/EOM " + "addheader() failed: " + traceback.format_exc() ) ex = str( @@ -224,7 +216,8 @@ class SOSMilter(Milter.Base): ) return Milter.REJECT else: - logging.debug(self.mconn_id + '/' + self.queue_id + "/EOM " + + logging.debug(self.mconn_id + + '/' + self.queue_id + "/EOM " + "No SPF-record found for {0}".format(self.env_from_domain) ) return Milter.CONTINUE @@ -239,6 +232,46 @@ class SOSMilter(Milter.Base): # Clean up any external resources here. logging.debug(self.mconn_id + "/CLOSE") return Milter.CONTINUE + + def get_spf_record(self, from_domain): + dns_response = None + try: + dns_response = dns.resolver.resolve(from_domain, 'TXT') + except dns.resolver.NoAnswer as e: + logging.warning(self.mconn_id + "/DNS " + e.msg) + # accept message if DNS-resolver fails + return None + except dns.resolver.NXDOMAIN as e: + logging.warning(self.mconn_id + + " /DNS " + e.msg + ) + # accept message if DNS-resolver fails + return None + except: + logging.error(self.mconn_id + + "/DNS Resolver-EXCEPTION: " + traceback.format_exc() + ) + # accept message if DNS-resolver fails + return None + for rdata in dns_response: + if re.match(r'^"v=spf1.*"$', rdata.to_text(), re.IGNORECASE): + # we´ve got a SPF match! + logging.debug(self.mconn_id + "/DNS SPFv1: {0}".format(rdata.to_text())) + # check if spf-record includes a redirect!? + m = re.match( + r'^.*redirect=(?P.+).*"', + rdata.to_text(), + re.IGNORECASE + ) + if m is not None: + # SPF redirect clause found + spf_redirect_domain = m.group('redirect_domain') + logging.info(self.mconn_id + + "/DNS SPF-redirect: {}".format(spf_redirect_domain) + ) + return self.get_spf_record(spf_redirect_domain) + else: + return rdata.to_text() if __name__ == "__main__": if 'LOG_LEVEL' in os.environ: @@ -301,7 +334,7 @@ if __name__ == "__main__": sys.exit(1) try: set_config_parameter("RESTARTABLE_SLEEPTIME", 2) - set_config_parameter("RESTARTABLE_TRIES", True) + set_config_parameter("RESTARTABLE_TRIES", 2) set_config_parameter('DEFAULT_SERVER_ENCODING', 'utf-8') set_config_parameter('DEFAULT_CLIENT_ENCODING', 'utf-8') server = Server(os.environ['LDAP_SERVER_URI'], get_info=NONE) diff --git a/app/sos-milter_eom.py b/app/sos-milter_eom.py deleted file mode 100644 index a2c3c2a..0000000 --- a/app/sos-milter_eom.py +++ /dev/null @@ -1,322 +0,0 @@ -import Milter -import sys -import traceback -import os -import logging -import string -import random -import re -import dns.resolver -from ldap3 import ( - Server, Connection, NONE, ALL, set_config_parameter, ALL_ATTRIBUTES, - ALL_OPERATIONAL_ATTRIBUTES, MODIFY_REPLACE, HASHED_NONE, HASHED_SALTED_SHA, -) -from ldap3.core.exceptions import LDAPException - -# Globals with mostly senseless defaults ;) -g_milter_name = 'sos-milter' -g_milter_socket = '/socket/' + g_milter_name -g_milter_reject_message = 'Security policy violation!' -g_milter_tmpfail_message = 'Service temporarily not available! Please try again later.' -g_re_domain = re.compile(r'^.*@(\S+)$', re.IGNORECASE) -g_re_spf_regex = re.compile(r'.*', re.IGNORECASE) -g_re_expected_txt_data = '' -g_loglevel = logging.INFO -g_milter_mode = 'test' -g_ignored_next_hops = {} -g_ldap_conn = None -g_ldap_binddn = '' -g_ldap_bindpw = '' - -class SOSMilter(Milter.Base): - # Each new connection is handled in an own thread - def __init__(self): - self.is_null_sender = False - self.env_from = None - self.env_from_domain = None - self.is_env_from_domain_in_ldap = False - self.spf_record = None - self.queue_id = None - self.next_hop = None - # https://stackoverflow.com/a/2257449 - self.mconn_id = g_milter_name + ': ' + ''.join( - random.choice(string.ascii_lowercase + string.digits) for _ in range(8) - ) - - # Not registered/used callbacks - @Milter.nocallback - def connect(self, IPname, family, hostaddr): - return Milter.CONTINUE - @Milter.nocallback - def hello(self, heloname): - return Milter.CONTINUE - @Milter.nocallback - def data(self): - return Milter.CONTINUE - @Milter.nocallback - def header(self, name, hval): - return Milter.CONTINUE - @Milter.nocallback - def eoh(self): - return Milter.CONTINUE - @Milter.nocallback - def body(self, chunk): - return Milter.CONTINUE - - def envfrom(self, mailfrom, *str): - try: - # DSNs/bounces are not relevant - if(mailfrom == '<>'): - self.is_null_sender = True - return Milter.CONTINUE - mailfrom = mailfrom.replace("<","") - mailfrom = mailfrom.replace(">","") - self.env_from = mailfrom - m = g_re_domain.match(self.env_from) - if m is None: - logging.error(self.mconn_id + "/FROM " + - "Could not determine domain of 5321.from=" + self.env_from - ) - self.is_null_sender = True - return Milter.CONTINUE - self.env_from_domain = m.group(1) - logging.debug(self.mconn_id + - "/FROM 5321_from_domain=" + self.env_from_domain - ) - # Check if env_from_domain is in ldap - if(g_ldap_conn is not None): - filter = os.environ['LDAP_QUERY_FILTER'] - filter = filter.replace("%d", self.env_from_domain) - try: - g_ldap_conn.search(os.environ['LDAP_SEARCH_BASE'], - filter, - attributes=[ALL_ATTRIBUTES] - ) - if len(g_ldap_conn.entries) != 0: - self.is_env_from_domain_in_ldap = True - logging.info(self.mconn_id + - "/FROM Domain {0} found in LDAP".format(self.env_from_domain) - ) - except LDAPException: - logging.error(self.mconn_id + "/FROM " + traceback.format_exc()) - - # Get TXT record of sender domain - dns_response = None - try: - dns_response = dns.resolver.resolve(self.env_from_domain, 'TXT') - except dns.resolver.NoAnswer as e: - logging.error(self.mconn_id + - " /FROM " + e.msg - ) - # accept message if DNS-resolver fails - return Milter.CONTINUE - except dns.resolver.NXDOMAIN as e: - logging.error(self.mconn_id + - " /FROM " + e.msg - ) - # accept message if DNS-resolver fails - return Milter.CONTINUE - except: - logging.error(self.mconn_id + " DNS-Resolver-EXCEPTION: " + traceback.format_exc()) - # accept message if DNS-resolver fails - return Milter.CONTINUE - for rdata in dns_response: - logging.debug(self.mconn_id + "/FROM DNS-TXT: {0}".format(rdata.to_text())) - if re.match(r'^"v=spf1.*"$', rdata.to_text(), re.IGNORECASE): - # we´ve got a SPF match! - self.spf_record = rdata.to_text() - # TODO: check if spf-record is a redirect!? - logging.debug(self.mconn_id + "/FROM Found SPFv1: {0}".format(self.spf_record)) - break - return Milter.CONTINUE - except: - logging.error(self.mconn_id + " FROM-EXCEPTION: " + traceback.format_exc()) - self.setreply('450','4.7.1', g_milter_tmpfail_message) - return Milter.TEMPFAIL - - def envrcpt(self, to, *str): - if self.is_null_sender == True: - return Milter.CONTINUE - self.next_hop = self.getsymval('{rcpt_host}') - if self.next_hop is None: - logging.error(self.mconn_id + "RCPT exception: could not retrieve milter-macro ({rcpt_host})") - else: - logging.debug(self.mconn_id + "/RCPT Next-Hop: {0}".format(self.next_hop)) - return Milter.CONTINUE - - # EOM is not optional and thus, always called by MTA - def eom(self): - # A queue-id will be generated after the first accepted RCPT TO - # and therefore not available until DATA command - self.queue_id = self.getsymval('i') - if self.queue_id is None: - logging.error(self.mconn_id + "EOM exception: could not retrieve milter-macro (i)!") - self.setreply('450','4.7.1', g_milter_tmpfail_message) - return Milter.TEMPFAIL - else: - logging.debug(self.mconn_id + "/EOM Queue-ID: {0}".format(self.queue_id)) - - if self.is_null_sender: - logging.info(self.mconn_id + '/' + self.queue_id + - "/EOM Skipping bounce/DSN message" - ) - return Milter.CONTINUE - if self.spf_record is not None: - logging.info(self.mconn_id + '/' + self.queue_id + "/EOM " + - "SPFv1: " + str(self.spf_record) - ) - logging.debug(self.mconn_id + '/' + self.queue_id + "/EOM " + - "next-hop=" + str(self.next_hop) - ) - if re.match(r'^".+-all"$', self.spf_record, re.IGNORECASE) is not None: - # SPF record is in restrictive mode - logging.debug(self.mconn_id + '/' + self.queue_id + "/EOM " + - "SPF-record is signaling a FAIL-policy (-all)" - ) - if g_re_spf_regex.match(self.spf_record) is not None: - logging.debug(self.mconn_id + '/' + self.queue_id + "/EOM" + - " SPF-record of 5321_from_domain=" + self.env_from_domain + - " permits us to relay this message" - ) - else: - # Expected 'include' not found in SPF-record - if self.next_hop in g_ignored_next_hops: - logging.info(self.mconn_id + '/' + self.queue_id + "/EOM " + - "Passing message due to ignored next-hop=" + self.next_hop - ) - return Milter.CONTINUE - if self.is_env_from_domain_in_ldap: - logging.info(self.mconn_id + '/' + self.queue_id + "/EOM " + - "5321_from_domain={0} (LDAP) has a broken SPF-record!".format(self.env_from_domain) - ) - ex = str( - "SPF-record (-all) of 5321_from_domain=" - + self.env_from_domain + " does not permit us to relay this message!" - ) - if g_milter_mode == 'test': - logging.info(self.mconn_id + '/' + self.queue_id + "/EOM " + ex) - try: - self.addheader( - 'X-SOS-Milter', - self.mconn_id + ' ' + self.env_from_domain + ': failed SPF-expectation' - ) - logging.debug(self.mconn_id + '/' + self.queue_id + "/EOM " + - 'test-mode: X-SOS-Milter header was added. ' - ) - except: - logging.error(self.mconn_id + '/' + self.queue_id + "/EOM " + - "addheader() failed: " + traceback.format_exc() - ) - else: - logging.warning(self.mconn_id + '/' + self.queue_id + "/EOM " + ex) - self.setreply('550','5.7.1', - self.mconn_id + ' ' + ex + ' ' + g_milter_reject_message - ) - return Milter.REJECT - else: - logging.debug(self.mconn_id + '/' + self.queue_id + "/EOM " + - "No SPF-record found for {0}".format(self.env_from_domain) - ) - return Milter.CONTINUE - - def abort(self): - # Client disconnected prematurely - return Milter.CONTINUE - - def close(self): - # Always called, even when abort is called. - # Clean up any external resources here. - return Milter.CONTINUE - -if __name__ == "__main__": - if 'LOG_LEVEL' in os.environ: - if re.match(r'^info$', os.environ['LOG_LEVEL'], re.IGNORECASE): - g_loglevel = logging.INFO - elif re.match(r'^warn|warning$', os.environ['LOG_LEVEL'], re.IGNORECASE): - g_loglevel = logging.WARN - elif re.match(r'^error$', os.environ['LOG_LEVEL'], re.IGNORECASE): - g_loglevel = logging.ERROR - elif re.match(r'debug', os.environ['LOG_LEVEL'], re.IGNORECASE): - g_loglevel = logging.DEBUG - logging.basicConfig( - filename=None, # log to stdout - format='%(asctime)s: %(levelname)s %(message)s', - level=g_loglevel - ) - if 'MILTER_MODE' in os.environ: - if re.match(r'^test|reject$',os.environ['MILTER_MODE'], re.IGNORECASE): - g_milter_mode = os.environ['MILTER_MODE'] - if 'MILTER_NAME' in os.environ: - g_milter_name = os.environ['MILTER_NAME'] - if 'MILTER_SOCKET' in os.environ: - g_milter_socket = os.environ['MILTER_SOCKET'] - if 'MILTER_REJECT_MESSAGE' in os.environ: - g_milter_reject_message = os.environ['MILTER_REJECT_MESSAGE'] - if 'MILTER_TMPFAIL_MESSAGE' in os.environ: - g_milter_tmpfail_message = os.environ['MILTER_TMPFAIL_MESSAGE'] - if 'SPF_REGEX' in os.environ: - try: - g_re_spf_regex = re.compile(os.environ['SPF_REGEX'], re.IGNORECASE) - except: - logging.error("ENV[SPF_REGEX] exception: " + traceback.format_exc()) - sys.exit(1) - if 'IGNORED_NEXT_HOPS' in os.environ: - try: - tmp_hops = os.environ['IGNORED_NEXT_HOPS'].split(',') - for next_hop in tmp_hops: - g_ignored_next_hops[next_hop] = 'ignore' - logging.debug("next-hops: " + str(g_ignored_next_hops)) - except: - logging.error("ENV[IGNORED_NEXT_HOPS] exception: " + traceback.format_exc()) - sys.exit(1) - if 'LDAP_ENABLED' in os.environ: - if 'LDAP_SERVER_URI' not in os.environ: - logging.error("ENV[LDAP_SERVER_URI] is mandatory!") - sys.exit(1) - if 'LDAP_BINDDN' not in os.environ: - logging.info("ENV[LDAP_BINDDN] not set! Continue...") - else: - g_ldap_binddn = os.environ['LDAP_BINDDN'] - if 'LDAP_BINDPW' not in os.environ: - logging.info("ENV[LDAP_BINDPW] not set! Continue...") - else: - g_ldap_bindpw = os.environ['LDAP_BINDPW'] - if 'LDAP_SEARCH_BASE' not in os.environ: - logging.error("ENV[LDAP_SEARCH_BASE] is mandatory!") - sys.exit(1) - if 'LDAP_QUERY_FILTER' not in os.environ: - logging.error("ENV[LDAP_QUERY_FILTER] is mandatory!") - sys.exit(1) - try: - set_config_parameter("RESTARTABLE_SLEEPTIME", 2) - set_config_parameter("RESTARTABLE_TRIES", True) - set_config_parameter('DEFAULT_SERVER_ENCODING', 'utf-8') - set_config_parameter('DEFAULT_CLIENT_ENCODING', 'utf-8') - server = Server(os.environ['LDAP_SERVER_URI'], get_info=NONE) - g_ldap_conn = Connection(server, - g_ldap_binddn, - g_ldap_bindpw, - auto_bind=True, - raise_exceptions=True, - client_strategy='RESTARTABLE' - ) - logging.info("LDAP-Connection steht. PID: " + str(os.getpid())) - except LDAPException as e: - print("LDAP-Exception: " + traceback.format_exc()) - sys.exit(1) - - try: - timeout = 600 - # Register to have the Milter factory create instances of your class: - Milter.factory = SOSMilter - # Tell the MTA which features we use - flags = Milter.ADDHDRS - Milter.set_flags(flags) - logging.info("Startup " + g_milter_name + - "@socket: " + g_milter_socket + - " in mode: " + g_milter_mode - ) - Milter.runmilter(g_milter_name,g_milter_socket,timeout,True) - logging.info("Shutdown " + g_milter_name) - except: - logging.error("MAIN-EXCEPTION: " + traceback.format_exc()) \ No newline at end of file diff --git a/docker-build.sh b/docker-build.sh deleted file mode 100755 index de3a25e..0000000 --- a/docker-build.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/bin/sh - -BRANCH="$(/usr/bin/git branch|/bin/grep \*|/usr/bin/awk {'print $2'})" -VERSION="$(/bin/cat VERSION)" -BASEOS="$(/bin/cat BASEOS)" -GO="" - -while getopts g opt -do - case $opt in - g) GO="go";; - esac -done - -if [ -z "${GO}" ] ; then - echo "Building sos-milter@docker on '${BASEOS}' for version '${VERSION}' in branch '${BRANCH}'!" - echo "GO serious with '-g'!" - exit 1 -fi - -IMAGES="sos-milter" - -for IMAGE in ${IMAGES}; do - /usr/bin/docker build \ - --pull=true \ - -t "${IMAGE}/${BASEOS}:${VERSION}_${BRANCH}" \ - -f "docker/${BASEOS}/Dockerfile" . -done diff --git a/docker/debian/Dockerfile b/docker/debian/Dockerfile deleted file mode 100644 index afbd7c9..0000000 --- a/docker/debian/Dockerfile +++ /dev/null @@ -1,26 +0,0 @@ -ARG http_proxy -ARG https_proxy -FROM debian -LABEL maintainer="Dominik Chilla " -LABEL git_repo="https://github.com/chillout2k/sos-milter" - -ENV DEBIAN_FRONTEND=noninteractive \ - TZ=Europe/Berlin - -RUN env; set -ex ; \ - apt-get -qq update \ - && apt-get -qq --no-install-recommends install \ - python3-pip python3-setuptools \ - libmilter1.0.1 libmilter-dev procps net-tools \ - gcc python3-dev \ - && /usr/bin/pip3 install pymilter \ - && /usr/bin/pip3 install dnspython \ - && /usr/bin/pip3 install ldap3 \ - && /bin/mkdir /config /socket /app \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* - -COPY app/*.py /app/ -COPY entrypoint.sh /entrypoint.sh - -ENTRYPOINT ["/entrypoint.sh"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7ae64ee --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +pymilter==1.0.5 +dnspython==2.3.0 +ldap3==2.9.1 \ No newline at end of file diff --git a/tests/miltertest.lua b/tests/miltertest.lua index b579a7b..7381b73 100644 --- a/tests/miltertest.lua +++ b/tests/miltertest.lua @@ -10,51 +10,17 @@ end mt.set_timeout(3) -- 5321.FROM + MACROS -mt.macro(conn, SMFIC_MAIL, '{client_addr}', "127.128.129.130", "i", "TestQueueId",'{rcpt_host}', "test.next-host") -if mt.mailfrom(conn, "dominik@dc-it-con.de") ~= nil then +mt.macro(conn, SMFIC_MAIL, '{client_addr}', "127.128.129.130") +if mt.mailfrom(conn, "postmaster@staging.zwackl.de") ~= nil then error "mt.mailfrom() failed" end if mt.getreply(conn) ~= SMFIR_CONTINUE then error "mt.mailfrom() unexpected reply" end --- 5321.RCPT -if mt.rcptto(conn, "info@dc-it-con.de") ~= nil then - error "mt.rcptto() failed" -end -if mt.getreply(conn) ~= SMFIR_CONTINUE then - error "mt.rcptto() unexpected reply" -end - --- EOM -if mt.eom(conn) ~= nil then - error "mt.eom() failed" -end -mt.echo("EOM: " .. mt.getreply(conn)) -if mt.getreply(conn) == SMFIR_CONTINUE then - mt.echo("EOM-continue") -elseif mt.getreply(conn) == SMFIR_REPLYCODE then - mt.echo("EOM-reject") -end - -if not mt.eom_check(conn, MT_HDRADD, "X-SOS-Milter") then - mt.echo("no header added") -else - mt.echo("X-SOS-Milter header added -> LDAP-Domain with broken SPF") -end - ---asdf --- 5321.FROM + MACROS -mt.macro(conn, SMFIC_MAIL, '{client_addr}', "127.128.129.130", "i", "TestQueueId",'{rcpt_host}', "test.next-hostx") -if mt.mailfrom(conn, "dominik@dc-it-con.de") ~= nil then - error "mt.mailfrom() failed" -end -if mt.getreply(conn) ~= SMFIR_CONTINUE then - error "mt.mailfrom() unexpected reply" -end - --- 5321.RCPT -if mt.rcptto(conn, "info@dc-it-con.de") ~= nil then +-- 5321.RCPT + MACROS +mt.macro(conn, SMFIC_RCPT, "i", "TestQueueId-1",'{rcpt_host}', "test.next-host") +if mt.rcptto(conn, "some@recipient.somewhere") ~= nil then error "mt.rcptto() failed" end if mt.getreply(conn) ~= SMFIR_CONTINUE then diff --git a/tests/miltertest_conn_reuse.lua b/tests/miltertest_conn_reuse.lua new file mode 100644 index 0000000..53c0f1f --- /dev/null +++ b/tests/miltertest_conn_reuse.lua @@ -0,0 +1,85 @@ +-- https://mopano.github.io/sendmail-filter-api/constant-values.html#com.sendmail.milter.MilterConstants +-- http://www.opendkim.org/miltertest.8.html + +-- socket must be defined as miltertest global variable (-D) +conn = mt.connect(socket) +if conn == nil then + error "mt.connect() failed" +end + +mt.set_timeout(3) + +-- First message within smtp-session +-- 5321.FROM + MACROS +mt.macro(conn, SMFIC_MAIL, '{client_addr}', "127.128.129.130") +if mt.mailfrom(conn, "postmaster@staging.zwackl.de") ~= nil then + error "mt.mailfrom() failed" +end +if mt.getreply(conn) ~= SMFIR_CONTINUE then + error "mt.mailfrom() unexpected reply" +end + +-- 5321.RCPT + MACROS +mt.macro(conn, SMFIC_RCPT, "i", "TestQueueId-1",'{rcpt_host}', "test.next-host") +if mt.rcptto(conn, "some@recipient.somewhere") ~= nil then + error "mt.rcptto() failed" +end +if mt.getreply(conn) ~= SMFIR_CONTINUE then + error "mt.rcptto() unexpected reply" +end + +-- EOM +if mt.eom(conn) ~= nil then + error "mt.eom() failed" +end +mt.echo("EOM: " .. mt.getreply(conn)) +if mt.getreply(conn) == SMFIR_CONTINUE then + mt.echo("EOM-continue") +elseif mt.getreply(conn) == SMFIR_REPLYCODE then + mt.echo("EOM-reject") +end + +if not mt.eom_check(conn, MT_HDRADD, "X-SOS-Milter") then + mt.echo("no header added") +else + mt.echo("X-SOS-Milter header added -> LDAP-Domain with broken SPF") +end + +-- Second message +-- 5321.FROM + MACROS +mt.macro(conn, SMFIC_MAIL, '{client_addr}', "127.128.129.130") +if mt.mailfrom(conn, "info@staging.zwackl.de") ~= nil then + error "mt.mailfrom() failed" +end +if mt.getreply(conn) ~= SMFIR_CONTINUE then + error "mt.mailfrom() unexpected reply" +end + +-- 5321.RCPT + MACROS +mt.macro(conn, SMFIC_RCPT, "i", "TestQueueId-2",'{rcpt_host}', "test.next-host") +if mt.rcptto(conn, "some@recipient.somewhere") ~= nil then + error "mt.rcptto() failed" +end +if mt.getreply(conn) ~= SMFIR_CONTINUE then + error "mt.rcptto() unexpected reply" +end + +-- EOM +if mt.eom(conn) ~= nil then + error "mt.eom() failed" +end +mt.echo("EOM: " .. mt.getreply(conn)) +if mt.getreply(conn) == SMFIR_CONTINUE then + mt.echo("EOM-continue") +elseif mt.getreply(conn) == SMFIR_REPLYCODE then + mt.echo("EOM-reject") +end + +if not mt.eom_check(conn, MT_HDRADD, "X-SOS-Milter") then + mt.echo("no header added") +else + mt.echo("X-SOS-Milter header added -> LDAP-Domain with broken SPF") +end + +-- DISCONNECT +mt.disconnect(conn) \ No newline at end of file diff --git a/tests/miltertest_redirect.lua b/tests/miltertest_redirect.lua new file mode 100644 index 0000000..775d223 --- /dev/null +++ b/tests/miltertest_redirect.lua @@ -0,0 +1,49 @@ +-- https://mopano.github.io/sendmail-filter-api/constant-values.html#com.sendmail.milter.MilterConstants +-- http://www.opendkim.org/miltertest.8.html + +-- socket must be defined as miltertest global variable (-D) +conn = mt.connect(socket) +if conn == nil then + error "mt.connect() failed" +end + +mt.set_timeout(3) + +-- First message within smtp-session +-- 5321.FROM + MACROS +mt.macro(conn, SMFIC_MAIL, '{client_addr}', "127.128.129.130") +if mt.mailfrom(conn, "blubb@gmx.de") ~= nil then + error "mt.mailfrom() failed" +end +if mt.getreply(conn) ~= SMFIR_CONTINUE then + error "mt.mailfrom() unexpected reply" +end + +-- 5321.RCPT + MACROS +mt.macro(conn, SMFIC_RCPT, "i", "TestQueueId-1",'{rcpt_host}', "test.next-hostx") +if mt.rcptto(conn, "some@recipient.somewhere") ~= nil then + error "mt.rcptto() failed" +end +if mt.getreply(conn) ~= SMFIR_CONTINUE then + error "mt.rcptto() unexpected reply" +end + +-- EOM +if mt.eom(conn) ~= nil then + error "mt.eom() failed" +end +mt.echo("EOM: " .. mt.getreply(conn)) +if mt.getreply(conn) == SMFIR_CONTINUE then + mt.echo("EOM-continue") +elseif mt.getreply(conn) == SMFIR_REPLYCODE then + mt.echo("EOM-reject") +end + +if not mt.eom_check(conn, MT_HDRADD, "X-SOS-Milter") then + mt.echo("no header added") +else + mt.echo("X-SOS-Milter header added -> LDAP-Domain with broken SPF") +end + +-- DISCONNECT +mt.disconnect(conn) \ No newline at end of file From abaadbc778b9b21bd38e982614142c295c642baf Mon Sep 17 00:00:00 2001 From: Dominik Chilla Date: Wed, 7 Jun 2023 21:22:23 +0200 Subject: [PATCH 6/6] cosmetic diffs --- Dockerfile | 1 - OCI/README.md | 11 +++++++++++ app/sos-milter.py | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) delete mode 120000 Dockerfile create mode 100644 OCI/README.md diff --git a/Dockerfile b/Dockerfile deleted file mode 120000 index 894720b..0000000 --- a/Dockerfile +++ /dev/null @@ -1 +0,0 @@ -docker/debian/Dockerfile \ No newline at end of file diff --git a/OCI/README.md b/OCI/README.md new file mode 100644 index 0000000..dc8a1b7 --- /dev/null +++ b/OCI/README.md @@ -0,0 +1,11 @@ +# OCI +**Note:** You need to be in the root path of the repo! + +Build local docker image: +``` +$ docker build -t sos-milter:local -f OCI/Dockerfile . +``` +Run it (with default ENV-values!): +``` +$ docker run -d sos-milter:local +``` diff --git a/app/sos-milter.py b/app/sos-milter.py index 740a3aa..bd90a86 100644 --- a/app/sos-milter.py +++ b/app/sos-milter.py @@ -345,7 +345,7 @@ if __name__ == "__main__": raise_exceptions=True, client_strategy='RESTARTABLE' ) - logging.info("LDAP-Connection steht. PID: " + str(os.getpid())) + logging.info("LDAP-Connection established. PID: " + str(os.getpid())) except LDAPException as e: print("LDAP-Exception: " + traceback.format_exc()) sys.exit(1)