From 4111454a57352d04378bdef916822893f8f6900c Mon Sep 17 00:00:00 2001 From: Dominik Chilla Date: Mon, 20 Jul 2020 00:09:33 +0200 Subject: [PATCH] 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