diff --git a/app/lam_config.py b/app/lam_config.py new file mode 100644 index 0000000..aa5fa91 --- /dev/null +++ b/app/lam_config.py @@ -0,0 +1,104 @@ +import re +import os +from lam_rex import rex_email +from lam_logger import log_info + +class LamConfigException(Exception): + def __init__(self, message): + self.message = message + def __str__(self): + return self.message + +class LamConfig(): + def __init__(self): + self.milter_mode = 'test' + self.milter_name = 'ldap-acl-milter' + self.milter_socket = '/socket/{}'.format(self.milter_name) + 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_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 # works only if milter_schema == True + 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 + + 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() + if 'MILTER_NAME' in os.environ: + self.milter_name = os.environ['MILTER_NAME'] + 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 + if 'LDAP_SERVER' not in os.environ: + raise LamConfigException("Missing ENV[LDAP_SERVER], e.g. {}".format(self.ldap_server)) + self.ldap_server = os.environ['LDAP_SERVER'] + 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 LamConfigException( + "Missing ENV[LDAP_BASE], e.g. {}".format(self.ldap_base) + ) + self.ldap_base = os.environ['LDAP_BASE'] + if 'LDAP_QUERY' not in os.environ: + if self.milter_schema == False: + raise LamConfigException( + "ENV[MILTER_SCHEMA] is disabled and ENV[LDAP_QUERY] is not set instead!" + ) + if 'LDAP_QUERY' in os.environ: + self.ldap_query = os.environ['LDAP_QUERY'] + if 'MILTER_SOCKET' in os.environ: + self.milter_socket = os.environ['MILTER_SOCKET'] + if 'MILTER_REJECT_MESSAGE' in os.environ: + self.milter_reject_message = os.environ['MILTER_REJECT_MESSAGE'] + if 'MILTER_TMPFAIL_MESSAGE' in os.environ: + self.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): + self.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 re.split(',|\s', whitelisted_rcpts_str): + if rex_email.match(whitelisted_rcpt) == None: + raise LamConfigException( + "ENV[MILTER_WHITELISTED_RCPTS]: invalid email address: {}" + .format(whitelisted_rcpt) + ) + else: + log_info("ENV[MILTER_WHITELISTED_RCPTS]: {}".format( + whitelisted_rcpt + )) + self.milter_whitelisted_rcpts[whitelisted_rcpt] = {} + 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]: {0}".format( + self.milter_trusted_authservid + )) + else: + raise LamConfigException("ENV[MILTER_TRUSTED_AUTHSERVID] is mandatory!") + log_info("ENV[MILTER_DKIM_ENABLED]: {0}".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 = os.environ['MILTER_MAX_RCPT'] + else: + raise LamConfigException("ENV[MILTER_MAX_RCPT] must be numeric!") + + diff --git a/app/lam_logger.py b/app/lam_logger.py new file mode 100644 index 0000000..5e651c3 --- /dev/null +++ b/app/lam_logger.py @@ -0,0 +1,34 @@ +import logging +import re +import os + +def init_logger(): + 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 log_info(message): + logging.info(message) + +def log_warning(message): + logging.warning(message) + +def log_error(message): + logging.error(message) + +def log_debug(message): + logging.debug(message) \ No newline at end of file diff --git a/app/lam_policy.py b/app/lam_policy.py new file mode 100644 index 0000000..bcb71b2 --- /dev/null +++ b/app/lam_policy.py @@ -0,0 +1,179 @@ +import re +from lam_logger import log_info, log_debug, log_error +from lam_rex import rex_domain +from ldap3 import ( + Server, Connection, NONE, set_config_parameter +) +from ldap3.core.exceptions import LDAPException + +class LamException(Exception): + def __init__(self, message="General exception message"): + self.message = message + def __str__(self) -> str: + return self.message + +class LamSoftException(LamException): + pass + +class LamHardException(LamException): + pass + +class LamPolicyBackendException(LamException): + pass + +class LamPolicyBackend(): + def __init__(self, lam_config): + self.config = lam_config + self.ldap_conn = None + try: + set_config_parameter("RESTARTABLE_SLEEPTIME", 2) + set_config_parameter("RESTARTABLE_TRIES", 2) + server = Server(self.config.ldap_server, get_info=NONE) + self.ldap_conn = Connection(server, + self.config.ldap_binddn, self.config.ldap_bindpw, + auto_bind=True, raise_exceptions=True, + client_strategy='RESTARTABLE' + ) + log_info("Connected to LDAP-server: {}".format(self.config.ldap_server)) + except LDAPException as e: + raise LamPolicyBackendException( + "Connection to LDAP-server failed: {}".format(str(e)) + ) from e + + def check_policy(self, **kwargs) -> str: + from_addr = kwargs['from_addr'] + rcpt_addr = kwargs['rcpt_addr'] + from_source = kwargs['from_source'] + lam_session = kwargs['lam_session'] + m = rex_domain.match(from_addr) + if m == None: + log_info("Could not determine domain of from={0}".format( + from_addr + )) + raise LamSoftException() + from_domain = m.group(1) + log_debug("from_domain={}".format(from_domain)) + m = rex_domain.match(rcpt_addr) + if m == None: + raise LamHardException( + "Could not determine domain of rcpt={0}".format( + rcpt_addr + ) + ) + rcpt_domain = m.group(1) + log_debug("rcpt_domain={}".format(rcpt_domain)) + try: + if self.config.milter_schema == True: + # LDAP-ACL-Milter schema + auth_method = '' + if self.config.milter_expect_auth == True: + auth_method = "(|(allowedClientAddr="+lam_session.client_addr+")%SASL_AUTH%%X509_AUTH%)" + if lam_session.sasl_user: + auth_method = auth_method.replace( + '%SASL_AUTH%',"(allowedSaslUser="+lam_session.sasl_user+")" + ) + else: + auth_method = auth_method.replace('%SASL_AUTH%','') + if lam_session.x509_subject and lam_session.x509_issuer: + auth_method = auth_method.replace('%X509_AUTH%', + "(&"+ + "(allowedx509subject="+lam_session.x509_subject+")"+ + "(allowedx509issuer="+lam_session.x509_issuer+")"+ + ")" + ) + else: + auth_method = auth_method.replace('%X509_AUTH%','') + log_debug("auth_method: {}".format(auth_method)) + 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( + "Literal wildcard sender (*@) is not " + + "allowed in wildcard mode!" + ) + if re.match(r'^\*@.+$', rcpt_addr, re.IGNORECASE): + raise LamHardException( + "Literal wildcard recipient (*@) is not " + + "allowed in wildcard mode!" + ) + self.ldap_conn.search(self.config.ldap_base, + "(&" + + auth_method + + "(|"+ + "(allowedRcpts=" + rcpt_addr + ")"+ + "(allowedRcpts=\\2a@" + rcpt_domain + ")"+ + "(allowedRcpts=\\2a@\\2a)"+ + ")"+ + "(|"+ + "(allowedSenders=" + from_addr + ")"+ + "(allowedSenders=\\2a@" + from_domain + ")"+ + "(allowedSenders=\\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") + self.ldap_conn.search(self.config.ldap_base, + "(&" + + auth_method + + "(allowedRcpts=" + query_to + ")" + + "(allowedSenders=" + query_from + ")" + + ")", + attributes=['policyID'] + ) + if len(self.ldap_conn.entries) == 0: + # Policy not found in LDAP + ex = "policy mismatch: from={0} from_src={1} rcpt={2}".format( + from_addr, from_source, rcpt_addr + ) + if self.config.milter_expect_auth == True: + ex = "policy mismatch: from={0} from_src={1} rcpt={2} auth_method={3}".format( + from_addr, from_source, rcpt_addr, auth_method + ) + raise LamHardException(ex) + elif len(self.ldap_conn.entries) == 1: + # Policy found in LDAP, but which one? + entry = self.ldap_conn.entries[0] + return "policy match: '{0}' from_src={1}".format( + entry.policyID.value, from_source + ) + elif len(self.ldap_conn.entries) > 1: + # Something went wrong!? There shouldn´t be more than one entries! + log_error("More than one policies found! from={0} rcpt={1} auth_method={2}".format( + from_addr, rcpt_addr, auth_method + )) + raise LamHardException("More than one policies found!") + else: + # Custom LDAP schema + # 'build' a LDAP query per recipient + # replace all placeholders in query templates + query = self.config.ldap_query.replace("%rcpt%", rcpt_addr) + query = query.replace("%from%", from_addr) + query = query.replace("%client_addr%", lam_session.client_addr) + query = query.replace("%sasl_user%", lam_session.sasl_user) + query = query.replace("%from_domain%", from_domain) + query = query.replace("%rcpt_domain%", rcpt_domain) + log_debug("LDAP query: {}".format(query)) + self.ldap_conn.search(self.config.ldap_base, query) + if len(self.ldap_conn.entries) == 0: + log_info( + "policy mismatch from={0} from_src={1} rcpt={2}".format( + from_addr, from_source, rcpt_addr + ) + ) + raise LamHardException("policy mismatch") + return "policy match: '{0}' from_src={1}".format( + entry.policyID.value, from_source + ) + except LDAPException as e: + log_error("LDAP exception: {}".format(str(e))) + raise LamSoftException("LDAP exception: " + str(e)) from e diff --git a/app/lam_rex.py b/app/lam_rex.py new file mode 100644 index 0000000..7ea9942 --- /dev/null +++ b/app/lam_rex.py @@ -0,0 +1,6 @@ +import re + +rex_domain = re.compile(r'^\S*@(\S+)$') +# http://emailregex.com/ -> Python +rex_email = re.compile(r"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)") +rex_srs = re.compile(r"^SRS0=.+=.+=(\S+)=(\S+)\@.+$") \ No newline at end of file diff --git a/app/ldap-acl-milter.py b/app/ldap-acl-milter.py index ceaa3b0..74686c5 100644 --- a/app/ldap-acl-milter.py +++ b/app/ldap-acl-milter.py @@ -1,54 +1,19 @@ import Milter -from ldap3 import ( - Server, Connection, NONE, set_config_parameter -) -from ldap3.core.exceptions import LDAPException import sys import traceback -import os -import logging import string import random import re import email.utils import authres +from lam_config import LamConfig, LamConfigException +from lam_rex import rex_domain, rex_srs +from lam_logger import init_logger, log_debug, log_info, log_warning, log_error +from lam_policy import LamPolicyBackend, LamPolicyBackendException, LamSoftException, LamHardException # 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 -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_milter_mode = 'test' -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_milter_dkim_enabled = False -g_milter_trusted_authservid = None -g_re_srs = re.compile(r"^SRS0=.+=.+=(\S+)=(\S+)\@.+$") -g_milter_max_rcpt_enabled = False -g_milter_max_rcpt = 1 - -class LamException(Exception): - def __init__(self, message="General exception message"): - self.message = message - def __str__(self): - return self.message - -class LamSoftException(LamException): - pass - -class LamHardException(LamException): - pass +g_config = None +g_policy_backend = None class LdapAclMilter(Milter.Base): # Each new connection is handled in an own thread @@ -57,12 +22,6 @@ class LdapAclMilter(Milter.Base): self.client_addr = None def do_log(self, **kwargs): - if 'level' not in kwargs: - print("do_log(): 'level' arg missing!") - sys.exit(1) - if 'log_message' not in kwargs: - print("do_log(): 'log_message' arg missing!") - sys.exit(1) log_line = '' if hasattr(self, 'mconn_id'): log_line = "{}".format(self.mconn_id) @@ -72,13 +31,13 @@ class LdapAclMilter(Milter.Base): log_line = "{0}/{1}".format(log_line, self.proto_stage) log_line = "{0} {1}".format(log_line, kwargs['log_message']) if kwargs['level'] == 'error': - logging.error(log_line) + log_error(log_line) elif kwargs['level'] == 'warn' or kwargs['level'] == 'warning': - logging.warning(log_line) + log_warning(log_line) elif kwargs['level'] == 'info': - logging.info(log_line) + log_info(log_line) elif kwargs['level'] == 'debug': - logging.debug(log_line) + log_debug(log_line) else: print("do_log(): invalid 'level' {}".format(kwargs['level'])) sys.exit(1) @@ -106,7 +65,7 @@ class LdapAclMilter(Milter.Base): self.passed_dkim_results = [] self.log_debug("reset(): {}".format(self.__dict__)) # https://stackoverflow.com/a/2257449 - self.mconn_id = g_milter_name + ': ' + ''.join( + self.mconn_id = g_config.milter_name + ': ' + ''.join( random.choice(string.ascii_lowercase + string.digits) for _ in range(8) ) @@ -118,12 +77,12 @@ class LdapAclMilter(Milter.Base): smtp_code = None smtp_ecode = None if kwargs['action'] == 'reject': - message = g_milter_reject_message + message = g_config.milter_reject_message smtp_code = '550' smtp_ecode = '5.7.1' smfir = Milter.REJECT elif kwargs['action'] == 'tmpfail': - message = g_milter_tmpfail_message + message = g_config.milter_tmpfail_message smtp_code = '450' smtp_ecode = '4.7.1' smfir = Milter.TEMPFAIL @@ -148,149 +107,6 @@ class LdapAclMilter(Milter.Base): self.setreply(smtp_code, smtp_ecode, message) return smfir - def check_policy(self, **kwargs): - from_addr = kwargs['from_addr'] - rcpt_addr = kwargs['rcpt_addr'] - from_source = kwargs['from_source'] - m = g_re_domain.match(from_addr) - if m == None: - self.log_info("Could not determine domain of from={0}".format( - from_addr - )) - raise LamSoftException() - from_domain = m.group(1) - self.log_debug("from_domain={}".format(from_domain)) - m = g_re_domain.match(rcpt_addr) - if m == None: - raise LamHardException( - "Could not determine domain of rcpt={0}".format( - rcpt_addr - ) - ) - rcpt_domain = m.group(1) - self.log_debug("rcpt_domain={}".format(rcpt_domain)) - 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%','') - self.log_debug("auth_method: {}".format(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'^\*@.+$', from_addr, re.IGNORECASE): - raise LamHardException( - "Literal wildcard sender (*@) is not " + - "allowed in wildcard mode!" - ) - if re.match(r'^\*@.+$', rcpt_addr, re.IGNORECASE): - raise LamHardException( - "Literal wildcard recipient (*@) is not " + - "allowed in wildcard mode!" - ) - g_ldap_conn.search(g_ldap_base, - "(&" + - auth_method + - "(|"+ - "(allowedRcpts=" + rcpt_addr + ")"+ - "(allowedRcpts=\\2a@" + rcpt_domain + ")"+ - "(allowedRcpts=\\2a@\\2a)"+ - ")"+ - "(|"+ - "(allowedSenders=" + from_addr + ")"+ - "(allowedSenders=\\2a@" + from_domain + ")"+ - "(allowedSenders=\\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") - g_ldap_conn.search(g_ldap_base, - "(&" + - auth_method + - "(allowedRcpts=" + query_to + ")" + - "(allowedSenders=" + query_from + ")" + - ")", - attributes=['policyID'] - ) - if len(g_ldap_conn.entries) == 0: - # Policy not found in LDAP - if g_milter_expect_auth == True: - self.log_info( - "policy mismatch: from={0} from_src={1} rcpt={2} auth_method={3}".format( - from_addr, from_source, rcpt_addr, auth_method - ) - ) - else: - self.log_info( - "policy mismatch: from={0} from_src={1} rcpt={2}".format( - from_addr, from_source, rcpt_addr - ) - ) - raise LamHardException("policy mismatch!") - elif len(g_ldap_conn.entries) == 1: - # Policy found in LDAP, but which one? - entry = g_ldap_conn.entries[0] - self.log_info("policy match: '{0}' from_src={1}".format( - entry.policyID.value, from_source - )) - elif len(g_ldap_conn.entries) > 1: - # Something went wrong!? There shouldn´t be more than one entries! - self.log_warn("More than one policies found! from={0} rcpt={1} auth_method={2}".format( - from_addr, rcpt_addr, auth_method - )) - raise LamHardException("More than one policies found!") - else: - # Custom LDAP schema - # 'build' a LDAP query per recipient - # replace all placeholders in query templates - query = g_ldap_query.replace("%rcpt%", rcpt_addr) - query = query.replace("%from%", from_addr) - query = query.replace("%client_addr%", self.client_addr) - query = query.replace("%sasl_user%", self.sasl_user) - query = query.replace("%from_domain%", from_domain) - query = query.replace("%rcpt_domain%", rcpt_domain) - self.log_debug("LDAP query: {}".format(query)) - g_ldap_conn.search(g_ldap_base, query) - if len(g_ldap_conn.entries) == 0: - self.log_info( - "policy mismatch from={0} from_src={1} rcpt={2}".format( - from_addr, from_source, rcpt_addr - ) - ) - raise LamHardException("policy mismatch") - self.log_info("policy match: '{0}' from_src={1}".format( - entry.policyID.value, from_source - )) - except LDAPException as e: - self.log_error("LDAP exception: {}".format(str(e))) - raise LamSoftException("LDAP exception: " + str(e)) from e; - return self.milter_action(action = 'continue') - # Not registered/used callbacks @Milter.nocallback def eoh(self): @@ -311,7 +127,7 @@ class LdapAclMilter(Milter.Base): def envfrom(self, mailfrom, *str): self.reset() self.proto_stage = 'FROM' - if g_milter_expect_auth: + if g_config.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 @@ -353,14 +169,14 @@ class LdapAclMilter(Milter.Base): # 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) + m_srs = rex_srs.match(mailfrom) if m_srs != None: self.log_info("Found SRS-encoded envelope-sender: {}".format(mailfrom)) mailfrom = m_srs.group(2) + '@' + m_srs.group(1) self.log_info("SRS envelope-sender replaced with: {}".format(mailfrom)) self.env_from = mailfrom.lower() self.log_debug("5321.from={}".format(self.env_from)) - m = g_re_domain.match(self.env_from) + m = rex_domain.match(self.env_from) if m == None: return self.milter_action( action = 'reject', @@ -374,23 +190,24 @@ class LdapAclMilter(Milter.Base): to = to.replace(">","") to = to.lower() self.log_debug("5321.rcpt={}".format(to)) - if to in g_milter_whitelisted_rcpts: + if to in g_config.milter_whitelisted_rcpts: return self.milter_action(action = 'continue') - if g_milter_dkim_enabled: + if g_config.milter_dkim_enabled: # Collect all envelope-recipients for later # investigation (EOM). Do not perform any # policy action at this protocol phase. self.env_rcpts.append(to) else: try: - return self.check_policy( - from_addr=self.env_from, rcpt_addr=to, from_source='5321.from' + ret = g_policy_backend.check_policy( + from_addr=self.env_from, rcpt_addr=to, from_source='5321.from', lam_session=self ) + self.log_info(ret) except LamSoftException as e: - if g_milter_mode == 'reject': + if g_config.milter_mode == 'reject': return self.milter_action(action = 'tmpfail') except LamHardException as e: - if g_milter_mode == 'reject': + if g_config.milter_mode == 'reject': return self.milter_action( action = 'reject', reason = e.message @@ -402,12 +219,12 @@ class LdapAclMilter(Milter.Base): def header(self, hname, hval): self.proto_stage = 'HDR' self.queue_id = self.getsymval('i') - if g_milter_dkim_enabled == True: + if g_config.milter_dkim_enabled == True: # Parse RFC-5322-From header if(hname.lower() == "From".lower()): hdr_5322_from = email.utils.parseaddr(hval) self.hdr_from = hdr_5322_from[1].lower() - m = re.match(g_re_domain, self.hdr_from) + m = re.match(rex_domain, self.hdr_from) if m is None: return self.milter_action( action = 'reject', @@ -424,7 +241,7 @@ class LdapAclMilter(Milter.Base): ar = authres.AuthenticationResultsHeader.parse( "{0}: {1}".format(hname, hval) ) - if ar.authserv_id.lower() == g_milter_trusted_authservid.lower(): + if ar.authserv_id.lower() == g_config.milter_trusted_authservid.lower(): for ar_result in ar.results: if ar_result.method.lower() == 'dkim': if ar_result.result.lower() == 'pass': @@ -441,13 +258,13 @@ class LdapAclMilter(Milter.Base): def eom(self): self.proto_stage = 'EOM' - if g_milter_max_rcpt_enabled: - if len(self.env_rcpts) > int(g_milter_max_rcpt): - if g_milter_mode == 'reject': + if g_config.milter_max_rcpt_enabled: + if len(self.env_rcpts) > int(g_config.milter_max_rcpt): + if g_config.milter_mode == 'reject': return self.milter_action(action='reject', reason='Too many recipients!') else: self.do_log("TEST-Mode: Too many recipients!") - if g_milter_dkim_enabled: + if g_config.milter_dkim_enabled: self.log_info("5321.from={0} 5322.from={1} 5322.from_domain={2} 5321.rcpt={3}".format( self.env_from, self.hdr_from, self.hdr_from_domain, self.env_rcpts )) @@ -464,21 +281,24 @@ class LdapAclMilter(Milter.Base): for rcpt in self.env_rcpts: try: # Check 5321.from against policy - self.check_policy( - from_addr=self.env_from, rcpt_addr=rcpt, from_source='5321.from' + ret = g_policy_backend.check_policy( + from_addr=self.env_from, rcpt_addr=rcpt, from_source='5321.from', lam_session=self ) + self.log_info(ret) except LamSoftException as e: - if g_milter_mode == 'reject': + self.log_info(str(e)) + if g_config.milter_mode == 'reject': return self.milter_action(action = 'tmpfail') else: - self.log_info("TEST-Mode: {}".format(e.message)) + self.log_info("TEST-Mode - tmpfail") except LamHardException as e: if self.dkim_aligned: try: # Check 5322.from against policy - self.check_policy( - from_addr=self.hdr_from, rcpt_addr=rcpt, from_source='5322.from' + ret = g_policy_backend.check_policy( + from_addr=self.hdr_from, rcpt_addr=rcpt, from_source='5322.from', lam_session=self ) + self.log_info(ret) self.log_info("5322.from={} authorized by DKIM signature".format( self.hdr_from )) @@ -488,7 +308,7 @@ class LdapAclMilter(Milter.Base): reject_message = True if reject_message: - if g_milter_mode == 'reject': + if g_config.milter_mode == 'reject': return self.milter_action( action = 'reject', reason = 'policy mismatch! Message rejected for all recipients!' @@ -511,120 +331,29 @@ class LdapAclMilter(Milter.Base): return self.milter_action(action = 'continue') if __name__ == "__main__": + init_logger() try: - 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 - ) - 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'].lower() - 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. {}".format(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. {}".format(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 re.split(',|\s', whitelisted_rcpts_str): - if g_re_email.match(whitelisted_rcpt) == None: - logging.error( - "ENV[MILTER_WHITELISTED_RCPTS]: invalid email address: {}" - .format(whitelisted_rcpt) - ) - sys.exit(1) - else: - logging.info("ENV[MILTER_WHITELISTED_RCPTS]: {}".format( - whitelisted_rcpt - )) - g_milter_whitelisted_rcpts[whitelisted_rcpt] = {} - if 'MILTER_DKIM_ENABLED' in os.environ: - g_milter_dkim_enabled = True - if 'MILTER_TRUSTED_AUTHSERVID' in os.environ: - g_milter_trusted_authservid = os.environ['MILTER_TRUSTED_AUTHSERVID'].lower() - logging.info("ENV[MILTER_TRUSTED_AUTHSERVID]: {0}".format( - g_milter_trusted_authservid - )) - else: - logging.error("ENV[MILTER_TRUSTED_AUTHSERVID] is mandatory!") - sys.exit(1) - logging.info("ENV[MILTER_DKIM_ENABLED]: {0}".format(g_milter_dkim_enabled)) - if 'MILTER_MAX_RCPT_ENABLED' in os.environ: - g_milter_max_rcpt_enabled = True - if 'MILTER_MAX_RCPT' in os.environ: - if os.environ['MILTER_MAX_RCPT'].isnumeric(): - g_milter_max_rcpt = os.environ['MILTER_MAX_RCPT'] - else: - print("ENV[MILTER_MAX_RCPT] must be numeric!") - sys.exit(1) - try: - 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) - except LDAPException as e: - raise Exception("Connection to LDAP-server failed: {}".format(str(e))) from e + g_config = LamConfig() + except LamConfigException as e: + log_info("A config error was raised: {}".format(e)) + sys.exit(1) + try: + g_policy_backend = LamPolicyBackend(g_config) + except LamPolicyBackendException as e: + log_error("An backend init error was raised: {}".format(e)) + sys.exit(1) + try: 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("Starting {0}@socket: {1} in mode {2}".format( - g_milter_name, g_milter_socket, g_milter_mode + log_info("Starting {0}@socket: {1} in mode {2}".format( + g_config.milter_name, g_config.milter_socket, g_config.milter_mode )) - Milter.runmilter(g_milter_name,g_milter_socket,timeout,True) - logging.info("Shutdown {}".format(g_milter_name)) + Milter.runmilter(g_config.milter_name, g_config.milter_socket, timeout, True) + log_info("Shutdown {}".format(g_config.milter_name)) except: - logging.error("MAIN-EXCEPTION: {}".format(traceback.format_exc())) + log_error("MAIN-EXCEPTION: {}".format(traceback.format_exc())) sys.exit(1)