code re-structured

This commit is contained in:
Dominik Chilla 2022-02-21 00:19:53 +01:00
parent a516ded2c8
commit 3f60e09a67
5 changed files with 379 additions and 327 deletions

104
app/lam_config.py Normal file
View File

@ -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!")

34
app/lam_logger.py Normal file
View File

@ -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)

179
app/lam_policy.py Normal file
View File

@ -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 *@<domain> cannot be a real address!
if re.match(r'^\*@.+$', from_addr, re.IGNORECASE):
raise LamHardException(
"Literal wildcard sender (*@<domain>) is not " +
"allowed in wildcard mode!"
)
if re.match(r'^\*@.+$', rcpt_addr, re.IGNORECASE):
raise LamHardException(
"Literal wildcard recipient (*@<domain>) 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

6
app/lam_rex.py Normal file
View File

@ -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+)\@.+$")

View File

@ -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 *@<domain> cannot be a real address!
if re.match(r'^\*@.+$', from_addr, re.IGNORECASE):
raise LamHardException(
"Literal wildcard sender (*@<domain>) is not " +
"allowed in wildcard mode!"
)
if re.match(r'^\*@.+$', rcpt_addr, re.IGNORECASE):
raise LamHardException(
"Literal wildcard recipient (*@<domain>) 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)