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 import Milter
from ldap3 import (
Server, Connection, NONE, set_config_parameter
)
from ldap3.core.exceptions import LDAPException
import sys import sys
import traceback import traceback
import os
import logging
import string import string
import random import random
import re import re
import email.utils import email.utils
import authres 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... # Globals...
g_milter_name = 'ldap-acl-milter' g_config = None
g_milter_socket = '/socket/' + g_milter_name g_policy_backend = None
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
class LdapAclMilter(Milter.Base): class LdapAclMilter(Milter.Base):
# Each new connection is handled in an own thread # Each new connection is handled in an own thread
@ -57,12 +22,6 @@ class LdapAclMilter(Milter.Base):
self.client_addr = None self.client_addr = None
def do_log(self, **kwargs): 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 = '' log_line = ''
if hasattr(self, 'mconn_id'): if hasattr(self, 'mconn_id'):
log_line = "{}".format(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, self.proto_stage)
log_line = "{0} {1}".format(log_line, kwargs['log_message']) log_line = "{0} {1}".format(log_line, kwargs['log_message'])
if kwargs['level'] == 'error': if kwargs['level'] == 'error':
logging.error(log_line) log_error(log_line)
elif kwargs['level'] == 'warn' or kwargs['level'] == 'warning': elif kwargs['level'] == 'warn' or kwargs['level'] == 'warning':
logging.warning(log_line) log_warning(log_line)
elif kwargs['level'] == 'info': elif kwargs['level'] == 'info':
logging.info(log_line) log_info(log_line)
elif kwargs['level'] == 'debug': elif kwargs['level'] == 'debug':
logging.debug(log_line) log_debug(log_line)
else: else:
print("do_log(): invalid 'level' {}".format(kwargs['level'])) print("do_log(): invalid 'level' {}".format(kwargs['level']))
sys.exit(1) sys.exit(1)
@ -106,7 +65,7 @@ class LdapAclMilter(Milter.Base):
self.passed_dkim_results = [] self.passed_dkim_results = []
self.log_debug("reset(): {}".format(self.__dict__)) self.log_debug("reset(): {}".format(self.__dict__))
# https://stackoverflow.com/a/2257449 # 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) random.choice(string.ascii_lowercase + string.digits) for _ in range(8)
) )
@ -118,12 +77,12 @@ class LdapAclMilter(Milter.Base):
smtp_code = None smtp_code = None
smtp_ecode = None smtp_ecode = None
if kwargs['action'] == 'reject': if kwargs['action'] == 'reject':
message = g_milter_reject_message message = g_config.milter_reject_message
smtp_code = '550' smtp_code = '550'
smtp_ecode = '5.7.1' smtp_ecode = '5.7.1'
smfir = Milter.REJECT smfir = Milter.REJECT
elif kwargs['action'] == 'tmpfail': elif kwargs['action'] == 'tmpfail':
message = g_milter_tmpfail_message message = g_config.milter_tmpfail_message
smtp_code = '450' smtp_code = '450'
smtp_ecode = '4.7.1' smtp_ecode = '4.7.1'
smfir = Milter.TEMPFAIL smfir = Milter.TEMPFAIL
@ -148,149 +107,6 @@ class LdapAclMilter(Milter.Base):
self.setreply(smtp_code, smtp_ecode, message) self.setreply(smtp_code, smtp_ecode, message)
return smfir 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 # Not registered/used callbacks
@Milter.nocallback @Milter.nocallback
def eoh(self): def eoh(self):
@ -311,7 +127,7 @@ class LdapAclMilter(Milter.Base):
def envfrom(self, mailfrom, *str): def envfrom(self, mailfrom, *str):
self.reset() self.reset()
self.proto_stage = 'FROM' self.proto_stage = 'FROM'
if g_milter_expect_auth: if g_config.milter_expect_auth:
try: try:
# this may fail, if no x509 client certificate was used. # this may fail, if no x509 client certificate was used.
# postfix only passes this macro to milters if the TLS connection # 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) # Strip out Simple Private Signature (PRVS)
mailfrom = re.sub(r"^prvs=.{10}=", '', mailfrom) mailfrom = re.sub(r"^prvs=.{10}=", '', mailfrom)
# SRS (https://www.libsrs2.org/srs/srs.pdf) # 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: if m_srs != None:
self.log_info("Found SRS-encoded envelope-sender: {}".format(mailfrom)) self.log_info("Found SRS-encoded envelope-sender: {}".format(mailfrom))
mailfrom = m_srs.group(2) + '@' + m_srs.group(1) mailfrom = m_srs.group(2) + '@' + m_srs.group(1)
self.log_info("SRS envelope-sender replaced with: {}".format(mailfrom)) self.log_info("SRS envelope-sender replaced with: {}".format(mailfrom))
self.env_from = mailfrom.lower() self.env_from = mailfrom.lower()
self.log_debug("5321.from={}".format(self.env_from)) 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: if m == None:
return self.milter_action( return self.milter_action(
action = 'reject', action = 'reject',
@ -374,23 +190,24 @@ class LdapAclMilter(Milter.Base):
to = to.replace(">","") to = to.replace(">","")
to = to.lower() to = to.lower()
self.log_debug("5321.rcpt={}".format(to)) 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') return self.milter_action(action = 'continue')
if g_milter_dkim_enabled: if g_config.milter_dkim_enabled:
# Collect all envelope-recipients for later # Collect all envelope-recipients for later
# investigation (EOM). Do not perform any # investigation (EOM). Do not perform any
# policy action at this protocol phase. # policy action at this protocol phase.
self.env_rcpts.append(to) self.env_rcpts.append(to)
else: else:
try: try:
return self.check_policy( ret = g_policy_backend.check_policy(
from_addr=self.env_from, rcpt_addr=to, from_source='5321.from' from_addr=self.env_from, rcpt_addr=to, from_source='5321.from', lam_session=self
) )
self.log_info(ret)
except LamSoftException as e: except LamSoftException as e:
if g_milter_mode == 'reject': if g_config.milter_mode == 'reject':
return self.milter_action(action = 'tmpfail') return self.milter_action(action = 'tmpfail')
except LamHardException as e: except LamHardException as e:
if g_milter_mode == 'reject': if g_config.milter_mode == 'reject':
return self.milter_action( return self.milter_action(
action = 'reject', action = 'reject',
reason = e.message reason = e.message
@ -402,12 +219,12 @@ class LdapAclMilter(Milter.Base):
def header(self, hname, hval): def header(self, hname, hval):
self.proto_stage = 'HDR' self.proto_stage = 'HDR'
self.queue_id = self.getsymval('i') self.queue_id = self.getsymval('i')
if g_milter_dkim_enabled == True: if g_config.milter_dkim_enabled == True:
# Parse RFC-5322-From header # Parse RFC-5322-From header
if(hname.lower() == "From".lower()): if(hname.lower() == "From".lower()):
hdr_5322_from = email.utils.parseaddr(hval) hdr_5322_from = email.utils.parseaddr(hval)
self.hdr_from = hdr_5322_from[1].lower() 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: if m is None:
return self.milter_action( return self.milter_action(
action = 'reject', action = 'reject',
@ -424,7 +241,7 @@ class LdapAclMilter(Milter.Base):
ar = authres.AuthenticationResultsHeader.parse( ar = authres.AuthenticationResultsHeader.parse(
"{0}: {1}".format(hname, hval) "{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: for ar_result in ar.results:
if ar_result.method.lower() == 'dkim': if ar_result.method.lower() == 'dkim':
if ar_result.result.lower() == 'pass': if ar_result.result.lower() == 'pass':
@ -441,13 +258,13 @@ class LdapAclMilter(Milter.Base):
def eom(self): def eom(self):
self.proto_stage = 'EOM' self.proto_stage = 'EOM'
if g_milter_max_rcpt_enabled: if g_config.milter_max_rcpt_enabled:
if len(self.env_rcpts) > int(g_milter_max_rcpt): if len(self.env_rcpts) > int(g_config.milter_max_rcpt):
if g_milter_mode == 'reject': if g_config.milter_mode == 'reject':
return self.milter_action(action='reject', reason='Too many recipients!') return self.milter_action(action='reject', reason='Too many recipients!')
else: else:
self.do_log("TEST-Mode: Too many recipients!") 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.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 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: for rcpt in self.env_rcpts:
try: try:
# Check 5321.from against policy # Check 5321.from against policy
self.check_policy( ret = g_policy_backend.check_policy(
from_addr=self.env_from, rcpt_addr=rcpt, from_source='5321.from' from_addr=self.env_from, rcpt_addr=rcpt, from_source='5321.from', lam_session=self
) )
self.log_info(ret)
except LamSoftException as e: 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') return self.milter_action(action = 'tmpfail')
else: else:
self.log_info("TEST-Mode: {}".format(e.message)) self.log_info("TEST-Mode - tmpfail")
except LamHardException as e: except LamHardException as e:
if self.dkim_aligned: if self.dkim_aligned:
try: try:
# Check 5322.from against policy # Check 5322.from against policy
self.check_policy( ret = g_policy_backend.check_policy(
from_addr=self.hdr_from, rcpt_addr=rcpt, from_source='5322.from' 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.log_info("5322.from={} authorized by DKIM signature".format(
self.hdr_from self.hdr_from
)) ))
@ -488,7 +308,7 @@ class LdapAclMilter(Milter.Base):
reject_message = True reject_message = True
if reject_message: if reject_message:
if g_milter_mode == 'reject': if g_config.milter_mode == 'reject':
return self.milter_action( return self.milter_action(
action = 'reject', action = 'reject',
reason = 'policy mismatch! Message rejected for all recipients!' reason = 'policy mismatch! Message rejected for all recipients!'
@ -511,120 +331,29 @@ class LdapAclMilter(Milter.Base):
return self.milter_action(action = 'continue') return self.milter_action(action = 'continue')
if __name__ == "__main__": if __name__ == "__main__":
init_logger()
try: try:
log_level = logging.INFO g_config = LamConfig()
if 'LOG_LEVEL' in os.environ: except LamConfigException as e:
if re.match(r'^info$', os.environ['LOG_LEVEL'], re.IGNORECASE): log_info("A config error was raised: {}".format(e))
log_level = logging.INFO sys.exit(1)
elif re.match(r'^warn|warning$', os.environ['LOG_LEVEL'], re.IGNORECASE): try:
log_level = logging.WARN g_policy_backend = LamPolicyBackend(g_config)
elif re.match(r'^error$', os.environ['LOG_LEVEL'], re.IGNORECASE): except LamPolicyBackendException as e:
log_level = logging.ERROR log_error("An backend init error was raised: {}".format(e))
elif re.match(r'debug', os.environ['LOG_LEVEL'], re.IGNORECASE): sys.exit(1)
log_level = logging.DEBUG try:
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
timeout = 600 timeout = 600
# Register to have the Milter factory create instances of your class: # Register to have the Milter factory create instances of your class:
Milter.factory = LdapAclMilter Milter.factory = LdapAclMilter
# Tell the MTA which features we use # Tell the MTA which features we use
flags = Milter.ADDHDRS flags = Milter.ADDHDRS
Milter.set_flags(flags) Milter.set_flags(flags)
logging.info("Starting {0}@socket: {1} in mode {2}".format( log_info("Starting {0}@socket: {1} in mode {2}".format(
g_milter_name, g_milter_socket, g_milter_mode g_config.milter_name, g_config.milter_socket, g_config.milter_mode
)) ))
Milter.runmilter(g_milter_name,g_milter_socket,timeout,True) Milter.runmilter(g_config.milter_name, g_config.milter_socket, timeout, True)
logging.info("Shutdown {}".format(g_milter_name)) log_info("Shutdown {}".format(g_config.milter_name))
except: except:
logging.error("MAIN-EXCEPTION: {}".format(traceback.format_exc())) log_error("MAIN-EXCEPTION: {}".format(traceback.format_exc()))
sys.exit(1) sys.exit(1)