diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..25ff4b0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,129 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ diff --git a/BASEOS b/BASEOS deleted file mode 100644 index 2dee175..0000000 --- a/BASEOS +++ /dev/null @@ -1 +0,0 @@ -debian diff --git a/README.md b/README.md index b8a7f8c..6ca53be 100644 --- a/README.md +++ b/README.md @@ -62,14 +62,14 @@ services: #LDAP_QUERY: (&(mail=%rcpt%)(|(amavisWhitelistSender=*@%from_domain%)(amavisWhitelistSender=%from%))) # LDAP_QUERY: (&(|(mail=%rcpt%)(mail=*@%rcpt_domain%))(|(amavisWhitelistSender=*@%from_domain%)(amavisWhitelistSender=%from%))) # This enables the use of own ldap-acl-milter LDAP schema. Default: False - # Setting MILTER_SCHEMA: True disables the LDAP_QUERY parameter! - MILTER_SCHEMA: 'True' - # If MILTER_SCHEMA_WILDCARD_DOMAIN is set to True, the milter allows *@domain + # Setting MILTER_SCHEMA: true disables the LDAP_QUERY parameter! + #MILTER_SCHEMA: 'true' + # If MILTER_SCHEMA_WILDCARD_DOMAIN is set to true, the milter allows *@domain # as valid sender/recipient addresses in LDAP. # This only works if MILTER_SCHEMA is enabled! MILTER_SCHEMA_WILDCARD_DOMAIN: 'False' # default: test. Possible: test, reject - MILTER_MODE: 'reject' + #MILTER_MODE: 'reject' MILTER_NAME: some-another-milter-name # Default: UNIX-socket located under /socket/ldap-acl-milter # https://pythonhosted.org/pymilter/namespacemilter.html#a266a6e09897499d8b1ae0e20f0d2be73 @@ -79,10 +79,23 @@ services: # Expect authentication information from LDAP like allowedClientAddr, # allowedSaslUser or allowedx509CN. This is usefull if the milter handles # outbound email traffic, where senders must authenticate before submission. - # Default: False (inbound mode) - MILTER_EXPECT_AUTH: 'True' - # Blank or comma separated list of valid email recipients to whitelist, - MILTER_WHITELISTED_RCPTS: 'postmaster@example.com,hostmaster@example.org' + # Default: false (inbound mode) + #MILTER_EXPECT_AUTH: 'true' + # Blank or comma separated list of valid email recipients to whitelist (default: empty) + #MILTER_WHITELISTED_RCPTS: 'postmaster@example.com,hostmaster@example.org' + # Allow null-sender (<>) for bounces/DSNs (default: disabled) + #MILTER_ALLOW_NULL_SENDER: 'true' + # Enable recipient count limits (default: disabled) + #MILTER_MAX_RCPT_ENABLED: 'true' + #MILTER_MAX_RCPT: 1 + # Enable DKIM checks (default: disabled). + # This enables the milter to use the + # sender address placed in the 5322.from header + # within policy checks, unless the DKIM authentication results + # are invalid. + # Enabling this feature also requires a DKIM validating milter + # BEFORE the ldap-acl-milter! + #MILTER_DKIM_ENABLED: 'true' hostname: ldap-acl-milter volumes: - "lam_socket:/socket/:rw" diff --git a/VERSION b/VERSION deleted file mode 100644 index d18f0e6..0000000 --- a/VERSION +++ /dev/null @@ -1 +0,0 @@ -19.04 diff --git a/app/lam.py b/app/lam.py new file mode 100644 index 0000000..55ac681 --- /dev/null +++ b/app/lam.py @@ -0,0 +1,373 @@ +import Milter +import traceback +import re +import email.utils +import authres +from lam_backends import g_config_backend, g_policy_backend +from lam_rex import g_rex_domain, g_rex_srs +from lam_log_backend import log_debug, log_info, log_warning, log_error +from lam_exceptions import LamSoftException, LamHardException +from lam_session import LamSession + +class LdapAclMilter(Milter.Base): + # Each new connection is handled in an own thread + def __init__(self): + self.session = None + + def milter_action(self, **kwargs) -> int: + if 'action' not in kwargs: + raise Exception("'action' kwarg is mandatory!") + message = None + smfir = None + smtp_code = None + smtp_ecode = None + if kwargs['action'] == 'reject': + message = g_config_backend.milter_reject_message + smtp_code = '550' + smtp_ecode = '5.7.1' + smfir = Milter.REJECT + elif kwargs['action'] == 'tmpfail': + message = g_config_backend.milter_tmpfail_message + smtp_code = '450' + smtp_ecode = '4.7.1' + smfir = Milter.TEMPFAIL + elif kwargs['action'] == 'continue': + message = 'continue' + smfir = Milter.CONTINUE + else: + raise Exception("Invalid 'action': {}".format(kwargs['action'])) + # override message + if 'message' in kwargs: + message = kwargs['message'] + # prepend queue-id to message if it´s already available (DATA and later) + if self.session.get_queue_id() != 'invalid': + message = " queue_id: {0} - {1}".format(self.session.get_queue_id(), message) + # append reason to message + if 'reason' in kwargs: + message = "{0} - reason: {1}".format(message, kwargs['reason']) + if kwargs['action'] == 'reject' or kwargs['action'] == 'tmpfail': + log_info( + "milter_action={0} message={1}".format(kwargs['action'], message), + self.session + ) + self.setreply(smtp_code, smtp_ecode, message) + return smfir + + def connect(self, IPname, family, hostaddr): + self.session = LamSession(hostaddr[0]) + self.session.set_proto_stage('CONNECT') + return self.milter_action(action = 'continue') + + def envfrom(self, mailfrom, *str): + self.session.reset() + self.session.set_proto_stage('FROM') + if g_config_backend.milter_expect_auth: + try: + # this may fail, if no x509 client certificate was used. + # postfix only passes this macro to milters if the TLS connection + # with the authenticating client was trusted in a x509 manner (CA trust)! + # Unfortunately, postfix only passes the CN-field of the subject/issuer DN :-/ + x509_subject = self.getsymval('{cert_subject}') + if x509_subject != None: + self.session.set_x509_subject(x509_subject) + log_debug( + "x509_subject={}".format(self.session.get_x509_subject()), + self.session + ) + x509_issuer = self.getsymval('{cert_issuer}') + if x509_issuer != None: + self.session.set_x509_issuer(x509_issuer) + log_debug( + "x509_issuer={}".format(self.session.get_x509_issuer()), + self.session + ) + except: + log_error( + "x509 exception: {}".format(traceback.format_exc()), + self.session + ) + try: + # this may fail, if no SASL authentication preceded + sasl_user = self.getsymval('{auth_authen}') + if sasl_user != None: + self.session.set_sasl_user(sasl_user) + log_debug( + "sasl_user={}".format(self.session.get_sasl_user()), + self.session + ) + except: + log_error( + "sasl_user exception: {}".format(traceback.format_exc()), + self.session + ) + log_info( + "auth: client_ip={0} sasl_user={1} x509_subject={2} x509_issuer={3}".format( + self.session.get_client_addr(), self.session.get_sasl_user(), + self.session.get_x509_subject(), self.session.get_x509_issuer() + ), + self.session + ) + if mailfrom == '<>': + self.session.set_null_sender(True) + if g_config_backend.milter_allow_null_sender and self.session.is_null_sender(): + log_info("Null-sender accepted - skipping policy checks", self.session) + else: + mailfrom = mailfrom.replace("<","") + mailfrom = mailfrom.replace(">","") + # BATV (https://tools.ietf.org/html/draft-levine-smtp-batv-01) + # Strip out Simple Private Signature (PRVS) + mailfrom = re.sub(r"^prvs=.{10}=", '', mailfrom) + # SRS (https://www.libsrs2.org/srs/srs.pdf) + m_srs = g_rex_srs.match(mailfrom) + if m_srs != None: + log_info( + "Found SRS-encoded envelope-sender: {}".format(mailfrom), + self.session + ) + mailfrom = m_srs.group(2) + '@' + m_srs.group(1) + log_info( + "SRS envelope-sender replaced with: {}".format(mailfrom), + self.session + ) + self.session.set_env_from(mailfrom.lower()) + log_debug("5321.from={}".format(self.session.get_env_from()), self.session) + m = g_rex_domain.match(self.session.get_env_from()) + if m == None: + return self.milter_action( + action = 'reject', + reason = "Could not determine domain of 5321.from={}".format( + self.session.get_env_from() + ) + ) + return self.milter_action(action = 'continue') + + def envrcpt(self, to, *str): + self.session.set_proto_stage('RCPT') + if g_config_backend.milter_allow_null_sender and self.session.is_null_sender(): + return self.milter_action(action = 'continue') + to = to.replace("<","") + to = to.replace(">","") + to = to.lower() + log_debug("5321.rcpt={}".format(to), self.session) + if to in g_config_backend.milter_whitelisted_rcpts: + log_info( + "Welcome-listed rcpt={} - skipping policy checks".format(to), + self.session + ) + return self.milter_action(action = 'continue') + if g_config_backend.milter_dkim_enabled: + # Collect all envelope-recipients for later + # investigation (EOM). Do not perform any + # policy action in this protocol stage. + self.session.add_env_rcpt(to) + else: + # DKIM disabled. Policy enforcement takes place here. + try: + g_policy_backend.check_policy( + self.session, + from_addr = self.session.get_env_from(), + rcpt_addr = to, + from_source = 'envelope' + ) + self.session.add_env_rcpt(to) + except LamSoftException as e: + if g_config_backend.milter_mode == 'reject': + return self.milter_action(action = 'tmpfail') + except LamHardException as e: + if g_config_backend.milter_mode == 'reject': + return self.milter_action( + action = 'reject', + reason = e.message + ) + else: + log_info("TEST-Mode: {}".format(e.message), self.session) + return self.milter_action(action = 'continue') + + def data(self): + self.session.set_proto_stage('DATA') + self.session.set_queue_id(self.getsymval('i')) + log_debug( + "Queue-id: {}".format(self.session.get_queue_id()), + self.session + ) + return self.milter_action(action = 'continue') + + def header(self, hname, hval): + self.session.set_proto_stage('HDR') + if g_config_backend.milter_allow_null_sender and self.session.is_null_sender(): + return self.milter_action(action = 'continue') + if g_config_backend.milter_dkim_enabled == True: + # Parse RFC-5322-From header + if(hname.lower() == "from"): + log_debug("hname={0}, hval={1}".format(hname, hval), self.session) + hdr_5322_from = email.utils.parseaddr(hval) + self.session.set_hdr_from(hdr_5322_from[1].lower()) + m = re.match(g_rex_domain, self.session.get_hdr_from()) + if m is None: + log_warning( + "Could not determine domain part of 5322.from={}".format( + self.session.get_hdr_from() + ), + self.session + ) + return self.milter_action(action = 'continue') + self.session.set_hdr_from_domain(m.group(1)) + log_debug( + "5322.from={0}, 5322.from_domain={1}".format( + self.session.get_hdr_from(), self.session.get_hdr_from_domain() + ), + self.session + ) + # Parse RFC-7601 Authentication-Results header + elif(hname.lower() == "authentication-results"): + if not self.session.get_hdr_from_domain(): + log_debug("DKIM validation impossible - no 5321.from_domain", self.session) + return self.milter_action(action = 'continue') + log_debug("hname={0}, hval={1}".format(hname, hval), self.session) + ar = None + try: + ar = authres.AuthenticationResultsHeader.parse( + "{0}: {1}".format(hname, hval) + ) + if ar.authserv_id.lower() == g_config_backend.milter_trusted_authservid.lower(): + for ar_result in ar.results: + if ar_result.method.lower() == 'dkim': + if ar_result.result.lower() == 'pass': + self.session.add_passed_dkim_result(ar_result.header_d.lower()) + log_debug( + "dkim=pass sdid={}".format(ar_result.header_d), + self.session + ) + self.session.set_dkim_valid(True) + else: + log_debug( + "Ignoring authentication results of {}".format(ar.authserv_id), + self.session + ) + except Exception as e: + log_warning("AR-parse exception: {0}".format(str(e)), self.session) + return self.milter_action(action = 'continue') + + # Not registered/used callbacks + @Milter.nocallback + def eoh(self): + return self.milter_action(action = 'continue') + @Milter.nocallback + def body(self, chunk): + return self.milter_action(action = 'continue') + + def eom(self): + self.session.set_proto_stage('EOM') + if g_config_backend.milter_max_rcpt_enabled: + if len(self.session.get_env_rcpts()) > int(g_config_backend.milter_max_rcpt): + if g_config_backend.milter_mode == 'reject': + return self.milter_action(action='reject', reason='Too many recipients!') + else: + log_error("TEST-Mode: Too many recipients!", self.session) + if g_config_backend.milter_allow_null_sender and self.session.is_null_sender(): + return self.milter_action(action = 'continue') + if g_config_backend.milter_dkim_enabled: + log_info( + "5321.from={0} 5322.from={1} 5322.from_domain={2} 5321.rcpt={3}".format( + self.session.get_env_from(), self.session.get_hdr_from(), + self.session.get_hdr_from_domain(), self.session.get_env_rcpts() + ), + self.session + ) + if self.session.is_dkim_valid(): + # There is at least one valid DKIM signature! + # Check if one of them is also aligned + for passed_dkim_sdid in self.session.get_passed_dkim_results(): + if self.session.get_hdr_from_domain().lower() == passed_dkim_sdid.lower(): + self.session.set_dkim_aligned(True) + log_info( + "Found aligned DKIM signature for SDID={0}".format( + passed_dkim_sdid + ), + self.session + ) + reject_message = False + for rcpt in self.session.get_env_rcpts(): + if rcpt in g_config_backend.milter_whitelisted_rcpts: + log_info("Welcome-listed rcpt={}".format(rcpt), self.session) + try: + # Check 5321.from <-> 5321.rcpt against policy + g_policy_backend.check_policy( + self.session, + from_addr=self.session.get_env_from(), + rcpt_addr=rcpt, + from_source='envelope' + ) + log_info( + "action=pass 5321.from={0} 5321.rcpt={1}".format( + self.session.get_env_from(), rcpt + ), + self.session + ) + except LamSoftException as e: + log_info(str(e), self.session) + if g_config_backend.milter_mode == 'reject': + return self.milter_action(action = 'tmpfail') + else: + log_info("TEST-Mode - tmpfail: {}".format(str(e)), self.session) + except LamHardException as e: + log_info(e.message, self.session) + if self.session.is_dkim_aligned(): + try: + # Check 5322.from <-> 5321.rcpt against policy + g_policy_backend.check_policy( + self.session, + from_addr=self.session.get_hdr_from(), + rcpt_addr=rcpt, + from_source='from-header' + ) + log_info( + "action=pass 5322.from={0} 5321.rcpt={1}".format( + self.session.get_hdr_from(), rcpt + ), + self.session + ) + except LamSoftException as e: + log_info(str(e), self.session) + if g_config_backend.milter_mode == 'reject': + return self.milter_action(action = 'tmpfail') + else: + log_info("TEST-Mode - tmpfail: {}".format(str(e)), self.session) + except LamHardException as e: + reject_message = True + else: + reject_message = True + if reject_message: + if g_config_backend.milter_mode == 'reject': + return self.milter_action( + action = 'reject', + reason = 'policy mismatch! Message rejected for all recipients!' + ) + else: + log_info( + "TEST-Mode: policy mismatch! Message would be rejected for all recipients!", + self.session + ) + else: + # * DKIM check disabled + # Iterate through all accepted envelope recipients and log success + for rcpt in self.session.get_env_rcpts(): + log_info( + "action=pass 5321.from={0} 5321.rcpt={1}".format( + self.session.get_env_from(), rcpt + ), + self.session + ) + # No policy violations so far :-) + return self.milter_action(action = 'continue') + + def abort(self): + # Client disconnected prematurely + self.session.set_proto_stage('ABORT') + return self.milter_action(action = 'continue') + + def close(self): + # Always called, even when abort is called. + # Clean up any external resources here. + self.session.set_proto_stage('CLOSE') + return self.milter_action(action = 'continue') diff --git a/app/lam_backends.py b/app/lam_backends.py new file mode 100644 index 0000000..7ef940c --- /dev/null +++ b/app/lam_backends.py @@ -0,0 +1,30 @@ +import traceback +from lam_exceptions import ( + LamInitException, LamPolicyBackendException, LamConfigBackendException +) +from lam_log_backend import init_log_backend +from lam_config_backend import LamConfigBackend +from lam_policy_backend import LamPolicyBackend + +init_log_backend() + +g_config_backend = None +try: + if g_config_backend is None: + g_config_backend = LamConfigBackend() +except LamConfigBackendException as e: + raise LamInitException(e.message) from e +except Exception as e: + raise LamInitException(traceback.format_exc()) from e + +# Instantiate the LDAP policy backend and +# establish a permanent connection with the LDAP server +# which will be reused on any milter connection +g_policy_backend = None +try: + if g_policy_backend is None: + g_policy_backend = LamPolicyBackend(g_config_backend) +except LamPolicyBackendException as e: + raise LamInitException(e.message) from e +except Exception as e: + raise LamInitException(traceback.format_exc()) from e \ No newline at end of file diff --git a/app/lam_config_backend.py b/app/lam_config_backend.py new file mode 100644 index 0000000..f2f9b6c --- /dev/null +++ b/app/lam_config_backend.py @@ -0,0 +1,157 @@ +import re +import os +from lam_exceptions import LamConfigBackendException +from lam_rex import g_rex_email +from lam_log_backend import log_info + +class LamConfigBackend(): + def __init__(self): + self.milter_name = 'ldap-acl-milter' + self.milter_mode = 'test' + self.milter_socket = '/socket/{}'.format(self.milter_name) + self.milter_timeout = 60 + self.milter_reject_message = 'Security policy violation!' + self.milter_tmpfail_message = 'Service temporarily not available! Please try again later.' + self.ldap_server = 'ldap://127.0.0.1:389' + self.ldap_server_connect_timeout = 3 + self.ldap_binddn = 'cn=ldap-reader,ou=binds,dc=example,dc=org' + self.ldap_bindpw = 'TopSecret;-)' + self.ldap_base = 'ou=lam,ou=services,dc=example,dc=org' + self.ldap_query = '(&(mail=%rcpt%)(allowedEnvelopeSender=%from%))' + self.milter_schema = False + self.milter_schema_wildcard_domain = False + self.milter_expect_auth = False + self.milter_whitelisted_rcpts = {} + self.milter_dkim_enabled = False + self.milter_trusted_authservid = None + self.milter_max_rcpt_enabled = False + self.milter_max_rcpt = 1 + self.milter_allow_null_sender = False + + if 'MILTER_NAME' in os.environ: + self.milter_name = os.environ['MILTER_NAME'] + + if 'MILTER_MODE' in os.environ: + if re.match(r'^test|reject$',os.environ['MILTER_MODE'], re.IGNORECASE): + self.milter_mode = os.environ['MILTER_MODE'].lower() + log_info("ENV[MILTER_MODE]: {}". format(self.milter_mode)) + + if 'MILTER_SOCKET' in os.environ: + self.milter_socket = os.environ['MILTER_SOCKET'] + log_info("ENV[MILTER_SOCKET]: {}".format(self.milter_socket)) + + if 'MILTER_TIMEOUT' in os.environ: + if os.environ['MILTER_TIMEOUT'].isnumeric(): + self.milter_timeout = int(os.environ['MILTER_TIMEOUT']) + else: + raise LamConfigBackendException("ENV[MILTER_TIMEOUT] must be numeric!") + log_info("ENV[MILTER_TIMEOUT]: {}".format(self.milter_timeout)) + + if 'MILTER_SCHEMA' in os.environ: + if re.match(r'^true$', os.environ['MILTER_SCHEMA'], re.IGNORECASE): + self.milter_schema = True + if 'MILTER_SCHEMA_WILDCARD_DOMAIN' in os.environ: + if re.match(r'^true$', os.environ['MILTER_SCHEMA_WILDCARD_DOMAIN'], re.IGNORECASE): + self.milter_schema_wildcard_domain = True + log_info("ENV[MILTER_SCHEMA]: {}".format(self.milter_schema)) + log_info( + "ENV[MILTER_SCHEMA_WILDCARD_DOMAIN]: {}".format( + self.milter_schema_wildcard_domain + ) + ) + + if 'LDAP_SERVER' not in os.environ: + raise LamConfigBackendException( + "Missing ENV[LDAP_SERVER], e.g. {}".format(self.ldap_server) + ) + self.ldap_server = os.environ['LDAP_SERVER'] + log_info("ENV[LDAP_SERVER]: {}".format(self.ldap_server)) + + if 'LDAP_SERVER_CONNECT_TIMEOUT' in os.environ: + if not os.environ['LDAP_SERVER_CONNECT_TIMEOUT'].isnumeric(): + raise LamConfigBackendException( + "ENV[LDAP_SERVER_CONNECT_TIMEOUT] must be numeric!" + ) + self.ldap_server_connect_timeout = int(os.environ['LDAP_SERVER_CONNECT_TIMEOUT']) + log_info("ENV[LDAP_SERVER_CONNECT_TIMEOUT]: {}".format( + self.ldap_server_connect_timeout + )) + + if 'LDAP_BINDDN' in os.environ: + self.ldap_binddn = os.environ['LDAP_BINDDN'] + if 'LDAP_BINDPW' in os.environ: + self.ldap_bindpw = os.environ['LDAP_BINDPW'] + + if 'LDAP_BASE' not in os.environ: + raise LamConfigBackendException( + "Missing ENV[LDAP_BASE], e.g. {}".format(self.ldap_base) + ) + self.ldap_base = os.environ['LDAP_BASE'] + log_info("ENV[LDAP_BASE]: {}".format(self.ldap_base)) + + if 'LDAP_QUERY' not in os.environ: + if self.milter_schema == False: + raise LamConfigBackendException( + "ENV[MILTER_SCHEMA] is disabled and ENV[LDAP_QUERY] is not set instead!" + ) + else: + self.ldap_query = os.environ['LDAP_QUERY'] + log_info("ENV[LDAP_QUERY]: {}".format(self.ldap_query)) + + if 'MILTER_REJECT_MESSAGE' in os.environ: + self.milter_reject_message = os.environ['MILTER_REJECT_MESSAGE'] + log_info("ENV[MILTER_REJECT_MESSAGE]: {}".format(self.milter_reject_message)) + + if 'MILTER_TMPFAIL_MESSAGE' in os.environ: + self.milter_tmpfail_message = os.environ['MILTER_TMPFAIL_MESSAGE'] + log_info("ENV[MILTER_TMPFAIL_MESSAGE]: {}".format(self.milter_tmpfail_message)) + + if 'MILTER_EXPECT_AUTH' in os.environ: + if re.match(r'^true$', os.environ['MILTER_EXPECT_AUTH'], re.IGNORECASE): + self.milter_expect_auth = True + log_info("ENV[MILTER_EXPECT_AUTH]: {}".format(self.milter_expect_auth)) + + if 'MILTER_WHITELISTED_RCPTS' in os.environ: + # A blank or comma separated list is expected + whitelisted_rcpts_str = os.environ['MILTER_WHITELISTED_RCPTS'] + for whitelisted_rcpt in re.split(',|\s', whitelisted_rcpts_str): + if g_rex_email.match(whitelisted_rcpt) == None: + raise LamConfigBackendException( + "ENV[MILTER_WHITELISTED_RCPTS]: invalid email address: {}" + .format(whitelisted_rcpt) + ) + else: + self.milter_whitelisted_rcpts[whitelisted_rcpt] = {} + log_info( + "ENV[MILTER_WHITELISTED_RCPTS]: {}".format( + self.milter_whitelisted_rcpts + ) + ) + + if 'MILTER_DKIM_ENABLED' in os.environ: + self.milter_dkim_enabled = True + if 'MILTER_TRUSTED_AUTHSERVID' in os.environ: + self.milter_trusted_authservid = os.environ['MILTER_TRUSTED_AUTHSERVID'].lower() + log_info( + "ENV[MILTER_TRUSTED_AUTHSERVID]: {}".format( + self.milter_trusted_authservid + ) + ) + else: + raise LamConfigBackendException("ENV[MILTER_TRUSTED_AUTHSERVID] is mandatory!") + log_info("ENV[MILTER_DKIM_ENABLED]: {}".format(self.milter_dkim_enabled)) + + if 'MILTER_MAX_RCPT_ENABLED' in os.environ: + self.milter_max_rcpt_enabled = True + if 'MILTER_MAX_RCPT' in os.environ: + if os.environ['MILTER_MAX_RCPT'].isnumeric(): + self.milter_max_rcpt = int(os.environ['MILTER_MAX_RCPT']) + log_info("ENV[MILTER_MAX_RCPT]: {}".format(self.milter_max_rcpt)) + else: + raise LamConfigBackendException("ENV[MILTER_MAX_RCPT] must be numeric!") + log_info("ENV[MILTER_MAX_RCPT_ENABLED]: {}".format(self.milter_max_rcpt_enabled)) + + if 'MILTER_ALLOW_NULL_SENDER' in os.environ: + if re.match(r'^true$', os.environ['MILTER_ALLOW_NULL_SENDER'], re.IGNORECASE): + self.milter_allow_null_sender = True + log_info("ENV[MILTER_ALLOW_NULL_SENDER]: {}".format(self.milter_allow_null_sender)) diff --git a/app/lam_exceptions.py b/app/lam_exceptions.py new file mode 100644 index 0000000..d8742a0 --- /dev/null +++ b/app/lam_exceptions.py @@ -0,0 +1,20 @@ +class LamException(Exception): + def __init__(self, message="General exception message"): + self.message = message + def __str__(self) -> str: + return self.message + +class LamInitException(LamException): + pass + +class LamSoftException(LamException): + pass + +class LamHardException(LamException): + pass + +class LamPolicyBackendException(LamException): + pass + +class LamConfigBackendException(LamException): + pass diff --git a/app/lam_log_backend.py b/app/lam_log_backend.py new file mode 100644 index 0000000..944b297 --- /dev/null +++ b/app/lam_log_backend.py @@ -0,0 +1,56 @@ +import logging +import re +import os +from typing import Optional +from lam_session import LamSession + +def init_log_backend(): + log_level = logging.INFO + if 'LOG_LEVEL' in os.environ: + if re.match(r'^info$', os.environ['LOG_LEVEL'], re.IGNORECASE): + log_level = logging.INFO + elif re.match(r'^warn|warning$', os.environ['LOG_LEVEL'], re.IGNORECASE): + log_level = logging.WARN + elif re.match(r'^error$', os.environ['LOG_LEVEL'], re.IGNORECASE): + log_level = logging.ERROR + elif re.match(r'debug', os.environ['LOG_LEVEL'], re.IGNORECASE): + log_level = logging.DEBUG + log_format = '%(asctime)s: %(levelname)s %(message)s ' + logging.basicConfig( + filename = None, # log to stdout + format = log_format, + level = log_level + ) + logging.info("Logger initialized") + +def do_log(level: str, log_message: str, session: Optional[LamSession] = None): + log_line = '-' + if session is not None: + if hasattr(session, 'mconn_id'): + log_line = "{}".format(session.get_mconn_id()) + if session is not None: + if session.get_queue_id() != 'invalid': + log_line = "{0}/{1}".format(log_line, session.get_queue_id()) + if session is not None and session.get_proto_stage() != 'invalid': + log_line = "{0}/{1}".format(log_line, session.get_proto_stage()) + log_line = "{0} {1}".format(log_line, log_message) + if level == 'error': + logging.error(log_line) + elif level == 'warn' or level == 'warning': + logging.warning(log_line) + elif level == 'info': + logging.info(log_line) + elif level == 'debug': + logging.debug(log_line) + +def log_error(log_message: str, session: Optional[LamSession] = None): + do_log('error', log_message, session) + +def log_warning(log_message: str, session: Optional[LamSession] = None): + do_log('warn', log_message, session) + +def log_info(log_message: str, session: Optional[LamSession] = None): + do_log('info', log_message, session) + +def log_debug(log_message: str, session: Optional[LamSession] = None): + do_log('debug', log_message, session) diff --git a/app/lam_policy_backend.py b/app/lam_policy_backend.py new file mode 100644 index 0000000..df67a79 --- /dev/null +++ b/app/lam_policy_backend.py @@ -0,0 +1,196 @@ +import re +from ldap3 import ( + Server, Connection, NONE, set_config_parameter, + SAFE_RESTARTABLE +) +from ldap3.core.exceptions import LDAPException +from lam_exceptions import ( + LamPolicyBackendException, LamHardException, LamSoftException +) +from lam_rex import g_rex_domain +from lam_config_backend import LamConfigBackend +from lam_session import LamSession +from lam_log_backend import log_info, log_debug + +class LamPolicyBackend(): + def __init__(self, lam_config: LamConfigBackend): + self.config = lam_config + self.ldap_conn = None + try: + set_config_parameter("RESTARTABLE_SLEEPTIME", 2) + set_config_parameter("RESTARTABLE_TRIES", 2) + server = Server( + host = self.config.ldap_server, + connect_timeout = self.config.ldap_server_connect_timeout, + get_info = NONE + ) + self.ldap_conn = Connection( + server, + self.config.ldap_binddn, + self.config.ldap_bindpw, + auto_bind = True, + raise_exceptions = True, + client_strategy = SAFE_RESTARTABLE + ) + log_info("policy: connected to LDAP-server: {}".format(self.config.ldap_server)) + except LDAPException as e: + raise LamPolicyBackendException( + "policy: Connection to LDAP-server failed: {}".format(str(e)) + ) from e + + def check_policy(self, session: LamSession, **kwargs): + from_addr = kwargs['from_addr'] + rcpt_addr = kwargs['rcpt_addr'] + from_source = kwargs['from_source'] + m = g_rex_domain.match(from_addr) + if m == None: + raise LamHardException( + "policy: Could not determine domain of from={}".format(from_addr) + ) + from_domain = m.group(1) + log_debug("policy: from_domain={}".format(from_domain), session) + m = g_rex_domain.match(rcpt_addr) + if m == None: + raise LamHardException( + "policy: Could not determine domain of rcpt={}".format( + rcpt_addr + ) + ) + rcpt_domain = m.group(1) + log_debug("policy: rcpt_domain={}".format(rcpt_domain), session) + try: + if self.config.milter_schema == True: + # LDAP-ACL-Milter schema enabled + auth_method = '' + if self.config.milter_expect_auth == True: + auth_method = "(|(allowedClientAddr={})%SASL_AUTH%%X509_AUTH%)".format( + session.get_client_addr() + ) + if session.get_sasl_user(): + auth_method = auth_method.replace( + '%SASL_AUTH%',"(allowedSaslUser={})".format( + session.get_sasl_user() + ) + ) + else: + auth_method = auth_method.replace('%SASL_AUTH%','') + if session.get_x509_subject() and session.get_x509_issuer(): + auth_method = auth_method.replace('%X509_AUTH%', + "(&"+ + "(allowedx509subject=" + session.get_x509_subject() + ")" + + "(allowedx509issuer=" + session.get_x509_issuer() + ")" + + ")" + ) + else: + auth_method = auth_method.replace('%X509_AUTH%','') + log_debug("policy: auth_method: {}".format(auth_method), session) + if self.config.milter_schema_wildcard_domain == True: + # The asterisk (*) character is in term of local part + # RFC5322 compliant and expected as a wildcard literal in this code. + # As the asterisk character is special in LDAP context, thus it must + # be ASCII-HEX encoded '\2a' (42 in decimal => answer to everything) + # for proper use in LDAP queries. + # In this case *@ cannot be a real address! + if re.match(r'^\*@.+$', from_addr, re.IGNORECASE): + raise LamHardException( + "policy: Literal wildcard sender (*@) is not " + + "allowed in wildcard mode!" + ) + if re.match(r'^\*@.+$', rcpt_addr, re.IGNORECASE): + raise LamHardException( + "policy: Literal wildcard recipient (*@) is not " + + "allowed in wildcard mode!" + ) + _, _, ldap_response, _ = self.ldap_conn.search(self.config.ldap_base, + "(&" + + auth_method + + "(|" + + "(allowedSenders=" + from_addr + ")" + + "(allowedSenders=\\2a@" + from_domain + ")" + + "(allowedSenders=\\2a@\\2a)" + + ")" + + "(&" + + "(!(deniedSenders=" + from_addr + "))" + + "(!(deniedSenders=\\2a@" + from_domain + "))" + + "(!(deniedSenders=\\2a@\\2a))" + + ")" + + "(|" + + "(allowedRcpts=" + rcpt_addr + ")" + + "(allowedRcpts=\\2a@" + rcpt_domain + ")" + + "(allowedRcpts=\\2a@\\2a)" + + ")" + + "(&" + + "(!(deniedRcpts=" + rcpt_addr + "))" + + "(!(deniedRcpts=\\2a@" + rcpt_domain + "))" + + "(!(deniedRcpts=\\2a@\\2a))" + + ")" + + ")", + attributes=['policyID'] + ) + else: + # Wildcard-domain DISABLED + # Asterisk (*) must be ASCII-HEX encoded for LDAP queries + query_from = from_addr.replace("*","\\2a") + query_to = rcpt_addr.replace("*","\\2a") + _, _, ldap_response, _ = self.ldap_conn.search(self.config.ldap_base, + "(&" + + auth_method + + "(allowedSenders=" + query_from + ")" + + "(!(deniedSenders=" + query_from + "))" + + "(allowedRcpts=" + query_to + ")" + + "(!(deniedRcpts=" + query_to + "))" + + ")", + attributes=['policyID'] + ) + if len(ldap_response) == 0: + # Policy not found in LDAP + raise LamHardException( + "policy: mismatch: from_src={0} from={1} rcpt={2}".format( + from_source, from_addr, rcpt_addr + ) + ) + elif len(ldap_response) == 1: + if from_source == 'from-header': + log_info( + "policy: 5322.from_domain={} authorized by DKIM signature".format( + from_domain + ), + session + ) + # Policy found in LDAP, but which one? + entry = ldap_response[0]['attributes'] + log_info( + "policy: match='{0}' from_src={1}".format( + entry['PolicyID'][0], from_source + ), + session + ) + elif len(ldap_response) > 1: + # Something went wrong!? There shouldn´t be more than one entries! + raise LamHardException( + "policy: More than one policies found! from={0} rcpt={1} auth_method={2}".format( + from_addr, rcpt_addr, auth_method + ) + ) + else: + # Custom LDAP schema + # replace all placeholders in query template + query = self.config.ldap_query.replace("%rcpt%", rcpt_addr) + query = query.replace("%from%", from_addr) + if self.config.milter_expect_auth: + query = query.replace("%client_addr%", session.get_client_addr()) + if session.get_sasl_user() is not None: + query = query.replace("%sasl_user%", session.get_sasl_user()) + query = query.replace("%from_domain%", from_domain) + query = query.replace("%rcpt_domain%", rcpt_domain) + log_debug("policy: LDAP query: {}".format(query), session) + _, _, ldap_response, _ = self.ldap_conn.search(self.config.ldap_base, query) + if len(ldap_response) == 0: + raise LamHardException( + "policy: mismatch from_src={0} from={1} rcpt={2}".format( + from_source, from_addr, rcpt_addr + ) + ) + log_info("policy: match from_src={}".format(from_source), session) + except LDAPException as e: + raise LamSoftException("policy: LDAP exception: " + str(e)) from e diff --git a/app/lam_rex.py b/app/lam_rex.py new file mode 100644 index 0000000..45f8b1d --- /dev/null +++ b/app/lam_rex.py @@ -0,0 +1,7 @@ +import re + +# globaly used regex definitions +g_rex_domain = re.compile(r'^\S*@(\S+)$') +# http://emailregex.com/ -> Python +g_rex_email = re.compile(r"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)") +g_rex_srs = re.compile(r"^SRS0=.+=.+=(\S+)=(\S+)\@.+$") \ No newline at end of file diff --git a/app/lam_session.py b/app/lam_session.py new file mode 100644 index 0000000..a7eeaae --- /dev/null +++ b/app/lam_session.py @@ -0,0 +1,97 @@ +import string +import random + +class LamSession(): + def __init__(self, client_addr: str): + self.client_addr = client_addr + self.reset() + + def reset(self): + self.proto_stage = 'invalid' + self.env_from = None + self.null_sender = False + self.sasl_user = None + self.x509_subject = None + self.x509_issuer = None + self.queue_id = 'invalid' + self.env_rcpts = [] + self.hdr_from = None + self.hdr_from_domain = None + self.dkim_valid = False + self.dkim_aligned = False + self.passed_dkim_results = [] + # https://stackoverflow.com/a/2257449 + self.mconn_id = ''.join( + random.choice(string.ascii_lowercase + string.digits) for _ in range(8) + ) + + def get_client_addr(self) -> str: + return self.client_addr + + def set_proto_stage(self, stage: str): + self.proto_stage = stage + def get_proto_stage(self) -> str: + return self.proto_stage + + def set_env_from(self, env_from: str): + self.env_from = env_from + def get_env_from(self) -> str: + return self.env_from + + def set_null_sender(self, null_sender: bool): + self.null_sender = null_sender + def is_null_sender(self) -> bool: + return self.null_sender + + def set_sasl_user(self, sasl_user: str): + self.sasl_user = sasl_user + def get_sasl_user(self) -> str: + return self.sasl_user + + def set_x509_subject(self, x509_subject: str): + self.x509_subject = x509_subject + def get_x509_subject(self) -> str: + return self.x509_subject + + def set_x509_issuer(self, x509_issuer: str): + self.x509_issuer = x509_issuer + def get_x509_issuer(self) -> str: + return self.x509_issuer + + def set_queue_id(self, queue_id: str): + self.queue_id = queue_id + def get_queue_id(self) -> str: + return self.queue_id + + def add_env_rcpt(self, rcpt: str): + self.env_rcpts.append(rcpt) + def get_env_rcpts(self) -> list: + return self.env_rcpts + + def set_hdr_from(self, hdr_from: str): + self.hdr_from = hdr_from + def get_hdr_from(self) -> str: + return self.hdr_from + + def set_hdr_from_domain(self, hdr_from_domain: str): + self.hdr_from_domain = hdr_from_domain + def get_hdr_from_domain(self) -> str: + return self.hdr_from_domain + + def set_dkim_valid(self, dkim_valid: bool): + self.dkim_valid = dkim_valid + def is_dkim_valid(self) -> bool: + return self.dkim_valid + + def set_dkim_aligned(self, dkim_aligned: bool): + self.dkim_aligned = dkim_aligned + def is_dkim_aligned(self) -> bool: + return self.dkim_aligned + + def add_passed_dkim_result(self, dkim_result: str): + self.passed_dkim_results.append(dkim_result) + def get_passed_dkim_results(self) -> list: + return self.passed_dkim_results + + def get_mconn_id(self) -> str: + return self.mconn_id \ No newline at end of file diff --git a/app/ldap-acl-milter.py b/app/ldap-acl-milter.py deleted file mode 100644 index f89e07b..0000000 --- a/app/ldap-acl-milter.py +++ /dev/null @@ -1,457 +0,0 @@ -import Milter -from ldap3 import ( - Server,ServerPool,Connection,NONE,LDAPOperationResult,set_config_parameter -) -import sys -import traceback -import os -import logging -import string -import random -import re -from timeit import default_timer as timer - -# Globals... -g_milter_name = 'ldap-acl-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_ldap_conn = None -# ...with mostly senseless defaults ;) -g_ldap_server = 'ldap://127.0.0.1:389' -g_ldap_binddn = 'cn=ldap-reader,ou=binds,dc=example,dc=org' -g_ldap_bindpw = 'TopSecret;-)' -g_ldap_base = 'ou=users,dc=example,dc=org' -g_ldap_query = '(&(mail=%rcpt%)(allowedEnvelopeSender=%from%))' -g_re_domain = re.compile(r'^\S*@(\S+)$') -# http://emailregex.com/ -> Python -g_re_email = re.compile(r"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)") -g_loglevel = logging.INFO -g_milter_mode = 'test' -g_milter_default_policy = 'reject' -g_milter_schema = False -g_milter_schema_wildcard_domain = False # works only if g_milter_schema == True -g_milter_expect_auth = False -g_milter_whitelisted_rcpts = {} -g_re_srs = re.compile(r"^SRS0=.+=.+=(\S+)=(\S+)\@.+$") - -class LdapAclMilter(Milter.Base): - # Each new connection is handled in an own thread - def __init__(self): - self.time_start = timer() - self.ldap_conn = g_ldap_conn - self.client_addr = None - self.env_from = None - self.env_from_domain = None - self.sasl_user = None - self.x509_subject = None - self.x509_issuer = None - # recipients list - self.env_rcpts = [] - # 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 hello(self, heloname) - # 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 connect(self, IPname, family, hostaddr): - self.client_addr = hostaddr[0] - logging.debug(self.mconn_id + - "/CONNECT client_addr=[" + self.client_addr + "]:" + str(hostaddr[1]) - ) - return Milter.CONTINUE - - def envfrom(self, mailfrom, *str): - try: - # this may fail, if no x509 client certificate was used. - # postfix only passes this macro to milters if the TLS connection - # with the authenticating client was trusted in a x509 manner! - # http://postfix.1071664.n5.nabble.com/verification-levels-and-Milter-tp91634p91638.html - # Unfortunately, postfix only passes the CN-field of the subject/issuer DN :-/ - x509_subject = self.getsymval('{cert_subject}') - if x509_subject != None: - self.x509_subject = x509_subject - logging.info(self.mconn_id + "/FROM x509_subject=" + self.x509_subject) - x509_issuer = self.getsymval('{cert_issuer}') - if x509_issuer != None: - self.x509_issuer = x509_issuer - logging.info(self.mconn_id + "/FROM x509_issuer=" + self.x509_issuer) - except: - logging.error(self.mconn_id + "/FROM x509 " + traceback.format_exc()) - try: - # this may fail, if no SASL authentication preceded - sasl_user = self.getsymval('{auth_authen}') - if sasl_user != None: - self.sasl_user = sasl_user - logging.info(self.mconn_id + "/FROM sasl_user=" + self.sasl_user) - except: - logging.error(self.mconn_id + "/FROM sasl_user " + traceback.format_exc()) - mailfrom = mailfrom.replace("<","") - mailfrom = mailfrom.replace(">","") - # BATV (https://tools.ietf.org/html/draft-levine-smtp-batv-01) - # Strip out Simple Private Signature (PRVS) - mailfrom = re.sub(r"^prvs=.{10}=", '', mailfrom) - # SRS (https://www.libsrs2.org/srs/srs.pdf) - m_srs = g_re_srs.match(mailfrom) - if m_srs != None: - logging.info(self.mconn_id + "/FROM " + - "Found SRS-encoded envelope-sender: " + mailfrom - ) - mailfrom = m_srs.group(2) + '@' + m_srs.group(1) - logging.info(self.mconn_id + "/FROM " + - "SRS envelope-sender replaced with: " + mailfrom - ) - self.env_from = mailfrom - m = g_re_domain.match(self.env_from) - if m == None: - 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.env_from_domain = m.group(1) - logging.debug(self.mconn_id + - "/FROM env_from_domain=" + self.env_from_domain - ) - return Milter.CONTINUE - - def envrcpt(self, to, *str): - time_start = timer() - to = to.replace("<","") - to = to.replace(">","") - if to in g_milter_whitelisted_rcpts: - time_end = timer() - self.env_rcpts.append({ - "rcpt": to, "action":'whitelisted_rcpt',"time_start":time_start,"time_end":time_end - }) - return Milter.CONTINUE - m = g_re_domain.match(to) - if m == None: - logging.error(self.mconn_id + "/RCPT " + - "Could not determine domain of 5321.to: " + to - ) - self.setreply('450','4.7.1', g_milter_tmpfail_message) - return Milter.TEMPFAIL - rcpt_domain = m.group(1) - logging.debug(self.mconn_id + - "/RCPT rcpt_domain=" + rcpt_domain - ) - time_end = None - try: - if g_milter_schema == True: - # LDAP-ACL-Milter schema - auth_method = '' - if g_milter_expect_auth == True: - auth_method = "(|(allowedClientAddr="+self.client_addr+")%SASL_AUTH%%X509_AUTH%)" - if self.sasl_user: - auth_method = auth_method.replace( - '%SASL_AUTH%',"(allowedSaslUser="+self.sasl_user+")" - ) - else: - auth_method = auth_method.replace('%SASL_AUTH%','') - if self.x509_subject and self.x509_issuer: - auth_method = auth_method.replace('%X509_AUTH%', - "(&"+ - "(allowedx509subject="+self.x509_subject+")"+ - "(allowedx509issuer="+self.x509_issuer+")"+ - ")" - ) - else: - auth_method = auth_method.replace('%X509_AUTH%','') - logging.debug(self.mconn_id + - " auth_method: " + auth_method - ) - if g_milter_schema_wildcard_domain == True: - # The asterisk (*) character is in term of local part - # RFC5322 compliant and expected as a wildcard literal in this code. - # As the asterisk character is special in LDAP context, thus it must - # be ASCII-HEX encoded '\2a' (42 in decimal => answer to everything) - # for proper use in LDAP queries. - # In this case *@ cannot be a real address! - if re.match(r'^\*@.+$', self.env_from, re.IGNORECASE): - logging.info(self.mconn_id + "/RCPT REJECT " + - "Literal wildcard sender (*@) is not " + - "allowed in wildcard mode!" - ) - self.setreply('550','5.7.1', - g_milter_reject_message + ' (' + self.mconn_id + ')' - ) - return Milter.REJECT - if re.match(r'^\*@.+$', to, re.IGNORECASE): - logging.info(self.mconn_id + "/RCPT REJECT " + - "Literal wildcard recipient (*@) is not " + - "allowed in wildcard mode!" - ) - self.setreply('550','5.7.1', - g_milter_reject_message + ' (' + self.mconn_id + ')' - ) - return Milter.REJECT - self.ldap_conn.search(g_ldap_base, - "(&" + - auth_method + - "(|"+ - "(allowedRcpts="+to+")"+ - "(allowedRcpts=\\2a@"+rcpt_domain+")"+ - "(allowedRcpts=\\2a@\\2a)"+ - ")"+ - "(|"+ - "(allowedSenders="+self.env_from+")"+ - "(allowedSenders=\\2a@"+self.env_from_domain+")"+ - "(allowedSenders=\\2a@\\2a)"+ - ")"+ - ")", - attributes=['policyID'] - ) - else: - # Asterisk must be ASCII-HEX encoded for LDAP queries - query_from = self.env_from.replace("*","\\2a") - query_to = to.replace("*","\\2a") - self.ldap_conn.search(g_ldap_base, - "(&" + - auth_method + - "(allowedRcpts="+query_to+")" + - "(allowedSenders="+query_from+")" + - ")", - attributes=['policyID'] - ) - time_end = timer() - if len(self.ldap_conn.entries) == 0: - # Policy not found in LDAP - self.env_rcpts.append({ - "rcpt": to, "action": g_milter_reject_message, - "time_start":time_start, "time_end":time_end - }) - if g_milter_expect_auth == True: - logging.info(self.mconn_id + "/RCPT " + "policy mismatch " - "5321.from=" + self.env_from + ", 5321.rcpt=" + to + - ", auth_method=" + auth_method - ) - else: - logging.info(self.mconn_id + "/RCPT " + "policy mismatch " - "5321.from=" + self.env_from + ", 5321.rcpt=" + to - ) - if g_milter_mode == 'reject': - logging.info(self.mconn_id + "/RCPT REJECT " - + g_milter_reject_message - ) - self.setreply('550','5.7.1', - g_milter_reject_message + ' (' + self.mconn_id + ')' - ) - return Milter.REJECT - else: - logging.info(self.mconn_id + "/RCPT TEST_MODE " + - g_milter_reject_message - ) - return Milter.CONTINUE - elif len(self.ldap_conn.entries) == 1: - # Policy found in LDAP, but which one? - entry = self.ldap_conn.entries[0] - logging.info(self.mconn_id + - "/RCPT Policy match: " + entry.policyID.value - ) - elif len(self.ldap_conn.entries) > 1: - # Something went wrong!? There shouldn´t be more than one entries! - logging.warn(self.mconn_id + "/RCPT More than one policies found! "+ - "5321.from=" + self.env_from + ", 5321.rcpt=" + to + - ", auth_method=" + auth_method - ) - self.setreply('550','5.7.1', - g_milter_reject_message + ' (' + self.mconn_id + ')' - ) - return Milter.REJECT - else: - # Custom LDAP schema - # 'build' a LDAP query per recipient - # replace all placeholders in query templates - query = g_ldap_query.replace("%rcpt%",to) - query = query.replace("%from%", self.env_from) - query = query.replace("%client_addr%", self.client_addr) - query = query.replace("%sasl_user%", self.sasl_user) - query = query.replace("%from_domain%", self.env_from_domain) - query = query.replace("%rcpt_domain%", rcpt_domain) - logging.debug(self.mconn_id + "/RCPT " + query) - self.ldap_conn.search(g_ldap_base, query) - time_end = timer() - if len(self.ldap_conn.entries) == 0: - self.env_rcpts.append({ - "rcpt": to, "action": g_milter_reject_message, - "time_start":time_start, "time_end":time_end - }) - logging.info(self.mconn_id + "/RCPT " + "policy mismatch " - "5321.from: " + self.env_from + " and 5321.rcpt: " + to - ) - if g_milter_mode == 'reject': - logging.info(self.mconn_id + "/RCPT REJECT " + g_milter_reject_message) - self.setreply('550','5.7.1', - g_milter_reject_message + ' (' + self.mconn_id + ')' - ) - return Milter.REJECT - else: - logging.info(self.mconn_id + "/RCPT TEST_MODE " + - g_milter_reject_message - ) - return Milter.CONTINUE - except LDAPOperationResult as e: - logging.warn(self.mconn_id + "/RCPT LDAP: " + str(e)) - self.setreply('451', '4.7.1', g_milter_tmpfail_message) - return Milter.TEMPFAIL - except: - logging.error(self.mconn_id + "/RCPT LDAP: " + traceback.format_exc()) - self.setreply('451', '4.7.1', g_milter_tmpfail_message) - return Milter.TEMPFAIL - self.env_rcpts.append({ - "rcpt": to, "action":'pass',"time_start":time_start,"time_end":time_end - }) - return Milter.CONTINUE - - def data(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') - try: - for rcpt in self.env_rcpts: - duration = rcpt['time_end'] - rcpt['time_start'] - logging.info(self.mconn_id + "/DATA " + self.queue_id + - ": 5321.from=" + self.env_from + " 5321.rcpt=" + - rcpt['rcpt'] + " action=" + rcpt['action'] + - " duration=" + str(duration) + "sec." - ) - except: - logging.warn(self.mconn_id + "/DATA " + self.queue_id + - ": " + traceback.format_exc()) - self.setreply('451', '4.7.1', g_milter_tmpfail_message) - return Milter.TEMPFAIL - return Milter.CONTINUE - - def eom(self): - # EOM is not optional and thus, always called by MTA - time_end = timer() - duration = time_end - self.time_start - logging.info(self.mconn_id + "/EOM " + self.queue_id + - " processed in " + str(duration) + " sec." - ) - 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__": - try: - 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_DEFAULT_POLICY' in os.environ: - if re.match(r'^reject|permit$',os.environ['MILTER_DEFAULT_POLICY'], re.IGNORECASE): - g_milter_default_policy = str(os.environ['MILTER_DEFAULT_POLICY']).lower() - else: - logging.warn("MILTER_DEFAULT_POLICY invalid value: " + - os.environ['MILTER_DEFAULT_POLICY'] - ) - if 'MILTER_NAME' in os.environ: - g_milter_name = os.environ['MILTER_NAME'] - if 'MILTER_SCHEMA' in os.environ: - if re.match(r'^true$', os.environ['MILTER_SCHEMA'], re.IGNORECASE): - g_milter_schema = True - if 'MILTER_SCHEMA_WILDCARD_DOMAIN' in os.environ: - if re.match(r'^true$', os.environ['MILTER_SCHEMA_WILDCARD_DOMAIN'], re.IGNORECASE): - g_milter_schema_wildcard_domain = True - if 'LDAP_SERVER' not in os.environ: - logging.error("Missing ENV[LDAP_SERVER], e.g. " + g_ldap_server) - sys.exit(1) - g_ldap_server = os.environ['LDAP_SERVER'] - if 'LDAP_BINDDN' in os.environ: - g_ldap_binddn = os.environ['LDAP_BINDDN'] - if 'LDAP_BINDPW' in os.environ: - g_ldap_bindpw = os.environ['LDAP_BINDPW'] - if 'LDAP_BASE' not in os.environ: - logging.error("Missing ENV[LDAP_BASE], e.g. " + g_ldap_base) - sys.exit(1) - g_ldap_base = os.environ['LDAP_BASE'] - if 'LDAP_QUERY' not in os.environ: - if g_milter_schema == False: - logging.error( - "ENV[MILTER_SCHEMA] is disabled and ENV[LDAP_QUERY] is not set instead!" - ) - sys.exit(1) - if 'LDAP_QUERY' in os.environ: - g_ldap_query = os.environ['LDAP_QUERY'] - 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 'MILTER_EXPECT_AUTH' in os.environ: - if re.match(r'^true$', os.environ['MILTER_EXPECT_AUTH'], re.IGNORECASE): - g_milter_expect_auth = True - if 'MILTER_WHITELISTED_RCPTS' in os.environ: - # A blank separated list is expected - whitelisted_rcpts_str = os.environ['MILTER_WHITELISTED_RCPTS'] -# for whitelisted_rcpt in whitelisted_rcpts_str.split(): - for whitelisted_rcpt in re.split(',|\s', whitelisted_rcpts_str): - if g_re_email.match(whitelisted_rcpt) == None: - logging.error( - "ENV[MILTER_WHITELISTED_RCPTS]: invalid email address: " + - whitelisted_rcpt - ) - sys.exit(1) - else: - logging.info("ENV[MILTER_WHITELISTED_RCPTS]: " + whitelisted_rcpt) - g_milter_whitelisted_rcpts[whitelisted_rcpt] = {} - set_config_parameter("RESTARTABLE_SLEEPTIME", 2) - set_config_parameter("RESTARTABLE_TRIES", 2) - server = Server(g_ldap_server, 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("Connected to LDAP-server: " + g_ldap_server) - timeout = 600 - # Register to have the Milter factory create instances of your class: - Milter.factory = LdapAclMilter - # 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()) - sys.exit(1) diff --git a/app/run_milter.py b/app/run_milter.py new file mode 100644 index 0000000..103a7a1 --- /dev/null +++ b/app/run_milter.py @@ -0,0 +1,32 @@ +import Milter +import sys +import traceback +from lam_exceptions import LamInitException +from lam_log_backend import log_info, log_error +try: + import lam_backends +except LamInitException as e: + log_error("Init exception: {}".format(e.message)) + sys.exit(1) +from lam import LdapAclMilter + +if __name__ == "__main__": + try: + # Register to have the Milter factory create instances of your class: + Milter.factory = LdapAclMilter + # Tell the MTA which features we use + flags = Milter.ADDHDRS + Milter.set_flags(flags) + log_info("Starting {}".format( + lam_backends.g_config_backend.milter_name + )) + Milter.runmilter( + lam_backends.g_config_backend.milter_name, + lam_backends.g_config_backend.milter_socket, + lam_backends.g_config_backend.milter_timeout, + True + ) + log_info("Shutdown {}".format(lam_backends.g_config_backend.milter_name)) + except: + log_error("MAIN-EXCEPTION: {}".format(traceback.format_exc())) + sys.exit(1) diff --git a/docker-build.sh b/docker-build.sh deleted file mode 100755 index 0e5adf5..0000000 --- a/docker-build.sh +++ /dev/null @@ -1,27 +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 ldap-acl-milter@docker on '${BASEOS}' for version '${VERSION}' in branch '${BRANCH}'!" - echo "GO serious with '-g'!" - exit 1 -fi - -IMAGES="ldap-acl-milter" - -for IMAGE in ${IMAGES}; do - /usr/bin/docker build \ - -t "${IMAGE}:${BRANCH}" \ - -f "docker/${BASEOS}/Dockerfile" . -done diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5f6231b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +authres<2 +pymilter<2 +ldap3<3 \ No newline at end of file diff --git a/tests/ip-conn_reuse.lua b/tests/ip-conn_reuse.lua new file mode 100644 index 0000000..310cf6a --- /dev/null +++ b/tests/ip-conn_reuse.lua @@ -0,0 +1,78 @@ +-- 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 +if mt.conninfo(conn, "blubb-ip.host", "127.128.129.130") ~= nil then + error "mt.conninfo() failed" +end + +mt.set_timeout(60) + +-- 5321.FROM +if mt.mailfrom(conn, "tester-ip@test.blah") ~= nil then + error "mt.mailfrom() failed" +end + +-- 5321.RCPT+MACROS +mt.macro(conn, SMFIC_RCPT, "i", "4CgSNs5Q9sz7SllQ") +if mt.rcptto(conn, "") ~= nil then + error "mt.rcptto() failed" +end + +-- 5322.HEADERS +if mt.header(conn, "fRoM", '"Blah Blubb" ') ~= nil then + error "mt.header(From) failed" +end +if mt.header(conn, "Authentication-REsuLTS", "my-auth-serv-id;\n dkim=pass header.d=test.blah header.s=selector1-test-blah header.b=mumble") ~= nil then + error "mt.header(Authentication-Results) failed" +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 + +-- CONNECTION REUSE + +-- 5321.FROM +if mt.mailfrom(conn, "tester-ip2@test.blah") ~= nil then + error "mt.mailfrom() failed" +end + +-- 5321.RCPT+MACROS +mt.macro(conn, SMFIC_RCPT, "i", "conn-reused-QID") +if mt.rcptto(conn, "") ~= nil then + error "mt.rcptto() failed" +end + +-- 5322.HEADERS +if mt.header(conn, "fRoM", '"Blah Blubb" ') ~= nil then + error "mt.header(From) failed" +end +if mt.header(conn, "Authentication-REsuLTS", "my-auth-serv-id;\n dkim=pass header.d=test.blah header.s=selector1-test-blah header.b=mumble") ~= nil then + error "mt.header(Authentication-Results) failed" +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 + +-- DISCONNECT +mt.disconnect(conn) \ No newline at end of file diff --git a/tests/ip-fail.lua b/tests/ip-fail.lua new file mode 100644 index 0000000..fc644f8 --- /dev/null +++ b/tests/ip-fail.lua @@ -0,0 +1,56 @@ +-- 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 +if mt.conninfo(conn, "blubb-ip.host", "127.6.6.6") ~= nil then + error "mt.conninfo() failed" +end + +mt.set_timeout(60) + +-- 5321.FROM +if mt.mailfrom(conn, "tester-ip-fail@test.blah") ~= nil then + error "mt.mailfrom() failed" +end +if mt.getreply(conn) == SMFIR_CONTINUE then + mt.echo("FROM-continue") +elseif mt.getreply(conn) == SMFIR_REPLYCODE then + error("FROM-reject") +end + +-- 5321.RCPT+MACROS +mt.macro(conn, SMFIC_RCPT, "i", "4CgSNs5Q9sz7SllQ") +if mt.rcptto(conn, "") ~= nil then + error "mt.rcptto() failed" +end +if mt.getreply(conn) == SMFIR_CONTINUE then + mt.echo("RCPT-continue") +elseif mt.getreply(conn) == SMFIR_REPLYCODE then + mt.echo("RCPT-reject") +end + +-- 5322.HEADERS +if mt.header(conn, "fRoM", '"Blah Blubb" ') ~= nil then + error "mt.header(From) failed" +end +if mt.header(conn, "Authentication-REsuLTS", "my-auth-serv-id;\n dkim=fail header.d=test.blah header.s=selector1-test-blah header.b=mumble") ~= nil then + error "mt.header(Authentication-Results) failed" +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 + +-- DISCONNECT +mt.disconnect(conn) \ No newline at end of file diff --git a/tests/ip-multiple_rcpts.lua b/tests/ip-multiple_rcpts.lua new file mode 100644 index 0000000..bc012dc --- /dev/null +++ b/tests/ip-multiple_rcpts.lua @@ -0,0 +1,63 @@ +-- 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 +if mt.conninfo(conn, "blubb-ip.host", "127.128.129.130") ~= nil then + error "mt.conninfo() failed" +end + +mt.set_timeout(60) + +-- 5321.FROM +if mt.mailfrom(conn, "tester-ipx@test.blah") ~= nil then + error "mt.mailfrom() failed" +end + +-- FIRST 5321.RCPT +if mt.rcptto(conn, "") ~= nil then + error "mt.rcptto() failed" +end +if mt.getreply(conn) == SMFIR_CONTINUE then + mt.echo("RCPT1-continue") +elseif mt.getreply(conn) == SMFIR_REPLYCODE then + mt.echo("RCPT1-reject") +end + +-- SECOND 5321.RCPT +if mt.rcptto(conn, "") ~= nil then + error "mt.rcptto() failed" +end +if mt.getreply(conn) == SMFIR_CONTINUE then + mt.echo("RCPT2-continue") +elseif mt.getreply(conn) == SMFIR_REPLYCODE then + mt.echo("RCPT2-reject") +end + +-- SET RCPT-MACRO +mt.macro(conn, SMFIC_RCPT, "i", "some-queue-id") + +-- 5322.HEADERS +if mt.header(conn, "fRoM", '"Blah Blubb" ') ~= nil then + error "mt.header(From) failed" +end +if mt.header(conn, "Authentication-REsuLTS", "my-auth-serv-id;\n dkim=pass header.d=test.blah header.s=selector1-test-blah header.b=mumble") ~= nil then + error "mt.header(Authentication-Results) failed" +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 + +-- DISCONNECT +mt.disconnect(conn) \ No newline at end of file diff --git a/tests/ip.lua b/tests/ip.lua new file mode 100644 index 0000000..fdcc892 --- /dev/null +++ b/tests/ip.lua @@ -0,0 +1,56 @@ +-- 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 +if mt.conninfo(conn, "blubb-ip.host", "127.128.129.130") ~= nil then + error "mt.conninfo() failed" +end + +mt.set_timeout(60) + +-- 5321.FROM +if mt.mailfrom(conn, "tester-ip@test.blah") ~= nil then + error "mt.mailfrom() failed" +end +if mt.getreply(conn) == SMFIR_CONTINUE then + mt.echo("FROM-continue") +elseif mt.getreply(conn) == SMFIR_REPLYCODE then + error("FROM-reject") +end + +-- 5321.RCPT+MACROS +mt.macro(conn, SMFIC_RCPT, "i", "4CgSNs5Q9sz7SllQ") +if mt.rcptto(conn, "") ~= nil then + error "mt.rcptto() failed" +end +if mt.getreply(conn) == SMFIR_CONTINUE then + mt.echo("RCPT-continue") +elseif mt.getreply(conn) == SMFIR_REPLYCODE then + mt.echo("RCPT-reject") +end + +-- 5322.HEADERS +if mt.header(conn, "fRoM", '"Blah Blubb" ') ~= nil then + error "mt.header(From) failed" +end +if mt.header(conn, "Authentication-REsuLTS", "my-auth-serv-id;\n dkim=pass header.d=test.blah header.s=selector1-test-blah header.b=mumble") ~= nil then + error "mt.header(Authentication-Results) failed" +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 + +-- DISCONNECT +mt.disconnect(conn) \ No newline at end of file diff --git a/tests/noauth.lua b/tests/noauth.lua new file mode 100644 index 0000000..16b1d7b --- /dev/null +++ b/tests/noauth.lua @@ -0,0 +1,46 @@ +-- 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 +if mt.conninfo(conn, "blubb-ip.host", "127.255.255.254") ~= nil then + error "mt.conninfo() failed" +end + +mt.set_timeout(60) + +-- 5321.FROM +if mt.mailfrom(conn, "tester-noauth@test.blah") ~= nil then + error "mt.mailfrom() failed" +end + +-- 5321.RCPT+MACROS +mt.macro(conn, SMFIC_RCPT, "i", "4CgSNs5Q9sz7SllQ") +if mt.rcptto(conn, "") ~= nil then + error "mt.rcptto() failed" +end + +-- 5322.HEADERS +if mt.header(conn, "fRoM", '"Blah Blubb" ') ~= nil then + error "mt.header(From) failed" +end +if mt.header(conn, "Authentication-REsuLTS", "my-auth-serv-id;\n dkim=pass header.d=test.blah header.s=selector1-test-blah header.b=mumble") ~= nil then + error "mt.header(Authentication-Results) failed" +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 + +-- DISCONNECT +mt.disconnect(conn) \ No newline at end of file diff --git a/tests/null_sender.lua b/tests/null_sender.lua new file mode 100644 index 0000000..8b284cd --- /dev/null +++ b/tests/null_sender.lua @@ -0,0 +1,48 @@ +-- 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 +if mt.conninfo(conn, "blubb-ip.host", "127.255.255.254") ~= nil then + error "mt.conninfo() failed" +end + +mt.set_timeout(60) + +-- 5321.FROM +if mt.mailfrom(conn, "<>") ~= nil then + error "mt.mailfrom() failed" +end +if mt.getreply(conn) == SMFIR_CONTINUE then + mt.echo("FROM-continue - null_sender allowed") +elseif mt.getreply(conn) == SMFIR_REPLYCODE then + error "FROM-reject - disconnect" +end + +-- 5321.RCPT+MACROS +mt.macro(conn, SMFIC_RCPT, "i", "4CgSNs5Q9sz7SllQ") +if mt.rcptto(conn, "") ~= nil then + error "mt.rcptto() failed" +end + +-- 5322.HEADERS +if mt.header(conn, "fRoM", '"MAILER DAEMON') ~= nil then + error "mt.header(From) failed" +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 + +-- DISCONNECT +mt.disconnect(conn) \ No newline at end of file diff --git a/tests/sasl-dkim-pass.lua b/tests/sasl-dkim-pass.lua new file mode 100644 index 0000000..d8966fe --- /dev/null +++ b/tests/sasl-dkim-pass.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 + +-- socket must be defined as miltertest global variable (-D) +conn = mt.connect(socket) +if conn == nil then + error "mt.connect() failed" +end +if mt.conninfo(conn, "localhost", "::1") ~= nil then + error "mt.conninfo() failed" +end + +mt.set_timeout(60) + +-- 5321.FROM+MACROS +mt.macro(conn, SMFIC_MAIL, "{auth_authen}", "blubb-user1") +if mt.mailfrom(conn, "tester@test.blah") ~= nil then + error "mt.mailfrom() failed" +end + +-- 5321.RCPT+MACROS +mt.macro(conn, SMFIC_RCPT, "i", "4CgSNs5Q9sz7SllQ") +if mt.rcptto(conn, "") ~= nil then + error "mt.rcptto() failed" +end + +-- 5322.HEADERS +if mt.header(conn, "fRoM", '"Blah Blubb" ') ~= nil then + error "mt.header(From) failed" +end +if mt.header(conn, "Authentication-RESULTS", "my-auth-serv-id;\n dkim=pass header.d=test.blah header.s=selector1-test-blah header.b=mumble") ~= nil then + error "mt.header(Authentication-Results) failed" +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 + +-- DISCONNECT +mt.disconnect(conn) \ No newline at end of file diff --git a/tests/sasl-fail.lua b/tests/sasl-fail.lua new file mode 100644 index 0000000..6a4ed42 --- /dev/null +++ b/tests/sasl-fail.lua @@ -0,0 +1,39 @@ +-- 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 +if mt.conninfo(conn, "localhost", "::1") ~= nil then + error "mt.conninfo() failed" +end + +mt.set_timeout(60) + +-- 5321.FROM+MACROS +mt.macro(conn, SMFIC_MAIL, "{auth_authen}", "blubb-user1") +if mt.mailfrom(conn, "tester-fail@test.blah") ~= nil then + error "mt.mailfrom() failed" +end + +-- 5321.RCPT+MACROS +mt.macro(conn, SMFIC_RCPT, "i", "4CgSNs5Q9sz7SllQ") +if mt.rcptto(conn, "") ~= nil then + error "mt.rcptto() failed" +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 + +-- DISCONNECT +mt.disconnect(conn) \ No newline at end of file diff --git a/tests/sasl-wildcard-dkim.lua b/tests/sasl-wildcard-dkim.lua new file mode 100644 index 0000000..c26c7d1 --- /dev/null +++ b/tests/sasl-wildcard-dkim.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 + +-- socket must be defined as miltertest global variable (-D) +conn = mt.connect(socket) +if conn == nil then + error "mt.connect() failed" +end +if mt.conninfo(conn, "localhost", "2001:db8:dead:beef::1234") ~= nil then + error "mt.conninfo() failed" +end + +mt.set_timeout(60) + +-- 5321.FROM+MACROS +mt.macro(conn, SMFIC_MAIL, "{auth_authen}", "blubb-user-wild") +if mt.mailfrom(conn, "tester-invalid@test.blah") ~= nil then + error "mt.mailfrom() failed" +end + +-- 5321.RCPT+MACROS +mt.macro(conn, SMFIC_RCPT, "i", "test-wildcard-qid") +if mt.rcptto(conn, "") ~= nil then + error "mt.rcptto() failed" +end + +-- 5322.HEADERS +if mt.header(conn, "fRoM", '"Blah Blubb" ') ~= nil then + error "mt.header(From) failed" +end +if mt.header(conn, "Authentication-RESULTS", "my-auth-serv-id;\n dkim=pass header.d=test.blah header.s=selector1-test-blah header.b=mumble") ~= nil then + error "mt.header(Authentication-Results) failed" +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 + +-- DISCONNECT +mt.disconnect(conn) \ No newline at end of file diff --git a/tests/sasl-wildcard.lua b/tests/sasl-wildcard.lua new file mode 100644 index 0000000..5059075 --- /dev/null +++ b/tests/sasl-wildcard.lua @@ -0,0 +1,44 @@ +-- 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 +if mt.conninfo(conn, "localhost", "::1") ~= nil then + error "mt.conninfo() failed" +end + +mt.set_timeout(60) + +-- 5321.FROM+MACROS +mt.macro(conn, SMFIC_MAIL, "{auth_authen}", "blubb-user-wild") +if mt.mailfrom(conn, "tester@test.blah") ~= nil then + error "mt.mailfrom() failed" +end + +-- 5321.RCPT+MACROS +mt.macro(conn, SMFIC_RCPT, "i", "test-wildcard-qid") +if mt.rcptto(conn, "") ~= nil then + error "mt.rcptto() failed" +end + +-- 5322.HEADERS +if mt.header(conn, "fRoM", '"Blah Blubb" ') ~= nil then + error "mt.header(From) failed" +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 + +-- DISCONNECT +mt.disconnect(conn) \ No newline at end of file diff --git a/tests/too_many_rcpts.lua b/tests/too_many_rcpts.lua new file mode 100644 index 0000000..6408857 --- /dev/null +++ b/tests/too_many_rcpts.lua @@ -0,0 +1,55 @@ +-- 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 +if mt.conninfo(conn, "blubb-ip.host", "127.128.129.130") ~= nil then + error "mt.conninfo() failed" +end + +mt.set_timeout(60) + +-- 5321.FROM +if mt.mailfrom(conn, "tester-sender@test.blah") ~= nil then + error "mt.mailfrom() failed" +end + +-- FIRST 5321.RCPT +if mt.rcptto(conn, "") ~= nil then + error "mt.rcptto() failed" +end +if mt.getreply(conn) == SMFIR_CONTINUE then + mt.echo("RCPT1-continue") +elseif mt.getreply(conn) == SMFIR_REPLYCODE then + mt.echo("RCPT1-reject") +end + +-- SECOND 5321.RCPT +if mt.rcptto(conn, "") ~= nil then + error "mt.rcptto() failed" +end +if mt.getreply(conn) == SMFIR_CONTINUE then + mt.echo("RCPT2-continue") +elseif mt.getreply(conn) == SMFIR_REPLYCODE then + mt.echo("RCPT2-reject") +end + +-- SET RCPT-MACRO +mt.macro(conn, SMFIC_RCPT, "i", "some-queue-id") + +-- 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 + +-- DISCONNECT +mt.disconnect(conn) \ No newline at end of file diff --git a/tests/welcome-listed_rcpt.lua b/tests/welcome-listed_rcpt.lua new file mode 100644 index 0000000..f86f279 --- /dev/null +++ b/tests/welcome-listed_rcpt.lua @@ -0,0 +1,56 @@ +-- 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 +if mt.conninfo(conn, "blubb-ip.host", "127.128.129.130") ~= nil then + error "mt.conninfo() failed" +end + +mt.set_timeout(60) + +-- 5321.FROM +if mt.mailfrom(conn, "some-sender@test.blah") ~= nil then + error "mt.mailfrom() failed" +end +if mt.getreply(conn) == SMFIR_CONTINUE then + mt.echo("FROM-continue") +elseif mt.getreply(conn) == SMFIR_REPLYCODE then + error("FROM-reject") +end + +-- 5321.RCPT+MACROS +mt.macro(conn, SMFIC_RCPT, "i", "4CgSNs5Q9sz7SllQ") +if mt.rcptto(conn, "") ~= nil then + error "mt.rcptto() failed" +end +if mt.getreply(conn) == SMFIR_CONTINUE then + mt.echo("RCPT-continue") +elseif mt.getreply(conn) == SMFIR_REPLYCODE then + mt.echo("RCPT-reject") +end + +-- 5322.HEADERS +if mt.header(conn, "fRoM", '"Blah Blubb" ') ~= nil then + error "mt.header(From) failed" +end +if mt.header(conn, "Authentication-REsuLTS", "my-auth-serv-id;\n dkim=pass header.d=test.blah header.s=selector1-test-blah header.b=mumble") ~= nil then + error "mt.header(Authentication-Results) failed" +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 + +-- DISCONNECT +mt.disconnect(conn) \ No newline at end of file diff --git a/tests/x509-denied-rcpts.lua b/tests/x509-denied-rcpts.lua new file mode 100644 index 0000000..e69de29 diff --git a/tests/x509-denied-senders.lua b/tests/x509-denied-senders.lua new file mode 100644 index 0000000..e69de29 diff --git a/tests/x509-dkim.lua b/tests/x509-dkim.lua new file mode 100644 index 0000000..e8fbed1 --- /dev/null +++ b/tests/x509-dkim.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 + +-- socket must be defined as miltertest global variable (-D) +conn = mt.connect(socket) +if conn == nil then + error "mt.connect() failed" +end +if mt.conninfo(conn, "localhost", "::1") ~= nil then + error "mt.conninfo() failed" +end + +mt.set_timeout(60) + +-- 5321.FROM+MACROS +mt.macro(conn, SMFIC_MAIL, "{cert_issuer}", "x509-issuer", "{cert_subject}", "x509-subject") +if mt.mailfrom(conn, "tester-x509-invalid@test.blah") ~= nil then + error "mt.mailfrom() failed" +end + +-- 5321.RCPT+MACROS +mt.macro(conn, SMFIC_RCPT, "i", "4CgSNs5Q9sz7SllQ") +if mt.rcptto(conn, "") ~= nil then + error "mt.rcptto() failed" +end + +-- 5322.HEADERS +if mt.header(conn, "fRoM", '"Blah Blubb" ') ~= nil then + error "mt.header(From) failed" +end +if mt.header(conn, "Authentication-RESULTS", "my-auth-serv-id;\n dkim=pass header.d=test.blah header.s=selector1-test-blah header.b=mumble") ~= nil then + error "mt.header(Authentication-Results) failed" +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 + +-- DISCONNECT +mt.disconnect(conn) \ No newline at end of file diff --git a/tests/x509.lua b/tests/x509.lua new file mode 100644 index 0000000..2d25108 --- /dev/null +++ b/tests/x509.lua @@ -0,0 +1,45 @@ +-- 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 +if mt.conninfo(conn, "localhost", "::1") ~= nil then + error "mt.conninfo() failed" +end + +mt.set_timeout(60) + +-- 5321.FROM+MACROS +mt.macro(conn, SMFIC_MAIL, "{cert_issuer}", "x509-issuer", "{cert_subject}", "x509-subject") +-- mt.macro(conn, SMFIC_MAIL, "{cert_subject}", "x509-subject") +if mt.mailfrom(conn, "tester-x509@test.blah") ~= nil then + error "mt.mailfrom() failed" +end + +-- 5321.RCPT+MACROS +mt.macro(conn, SMFIC_RCPT, "i", "4CgSNs5Q9sz7SllQ") +if mt.rcptto(conn, "") ~= nil then + error "mt.rcptto() failed" +end + +-- 5322.HEADERS +if mt.header(conn, "fRoM", '"Blah Blubb" ') ~= nil then + error "mt.header(From) failed" +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 + +-- DISCONNECT +mt.disconnect(conn) \ No newline at end of file