153 lines
4.7 KiB
Python
153 lines
4.7 KiB
Python
#!/usr/bin/env python3
|
|
|
|
from configparser import ConfigParser
|
|
from email.utils import parseaddr
|
|
import ipaddress
|
|
import logging
|
|
import os
|
|
import pwd
|
|
import re
|
|
import subprocess
|
|
|
|
import Milter
|
|
|
|
|
|
SOCKETPATH = "/run/csc-milter/csc-milter.sock"
|
|
CONFIG_PATH = '/etc/csc/csc_milter.ini'
|
|
TIMEOUT = 5
|
|
MYORIGIN = ''
|
|
MYNETWORKS = []
|
|
REJECT_MESSAGE = 'You must be authenticated to send from a CSC address.'
|
|
# The regex looks for e.g. "by mail.csclub.uwaterloo.ca" in the Received: header
|
|
RECEIVED_REGEX = None
|
|
|
|
logger = logging.getLogger(__name__)
|
|
logger.setLevel(logging.DEBUG)
|
|
log_handler = logging.StreamHandler()
|
|
log_handler.setLevel(logging.DEBUG)
|
|
log_handler.setFormatter(logging.Formatter('%(levelname)s: %(message)s'))
|
|
logger.addHandler(log_handler)
|
|
|
|
|
|
def is_in_myorigin(domain):
|
|
return domain == MYORIGIN or domain.endswith('.' + MYORIGIN)
|
|
|
|
|
|
def is_in_mynetworks(addr_str):
|
|
addr = ipaddress.ip_address(addr_str)
|
|
return any(addr in subnet for subnet in MYNETWORKS)
|
|
|
|
|
|
class CSCMilter(Milter.Base):
|
|
|
|
def __init__(self): # A new instance with each new connection.
|
|
super()
|
|
|
|
def connect(self, IPname, family, hostaddr):
|
|
"""
|
|
Called on connect to MTA.
|
|
|
|
:param IPname: the PTR name or bracketed IP of the SMTP client
|
|
:param family: one of (socket.AF_INET, socket.AF_INET6, socket.AF_UNIX)
|
|
:param hostaddr: a tuple or string with peer IP or socketname
|
|
|
|
Examples:
|
|
(self, 'ip068.subnet71.example.com', AF_INET, ('215.183.71.68', 4720) )
|
|
(self, 'ip6.mxout.example.com', AF_INET6, ('3ffe:80e8:d8::1', 4720, 1, 0) )
|
|
"""
|
|
self.IP = hostaddr[0]
|
|
# accept clients on the local network
|
|
if is_in_mynetworks(self.IP):
|
|
return Milter.ACCEPT
|
|
self.daemon_port = self.getsymval('{daemon_port}')
|
|
# We only want to filter messages arriving on the SMTP ports
|
|
if self.daemon_port is not None and int(self.daemon_port) not in [25, 465]:
|
|
return Milter.ACCEPT
|
|
return Milter.CONTINUE
|
|
|
|
def envfrom(self, mailfrom, *args):
|
|
# accept authenticated users
|
|
if self.getsymval('{auth_type}') is not None:
|
|
return Milter.ACCEPT
|
|
self.mailfrom = mailfrom
|
|
return Milter.CONTINUE
|
|
|
|
def header(self, field, value):
|
|
"""Called for each header field in the message body."""
|
|
field = field.lower()
|
|
# If the message was originally sent through a CSC mail server, then this is
|
|
# a valid forwarding
|
|
if field == 'received':
|
|
if RECEIVED_REGEX.search(value) is not None:
|
|
return Milter.ACCEPT
|
|
return Milter.CONTINUE
|
|
if field != 'from':
|
|
return Milter.CONTINUE
|
|
addr = parseaddr(value)[1]
|
|
if '@' not in addr:
|
|
return Milter.CONTINUE
|
|
domain = addr.split('@')[1].lower()
|
|
if not is_in_myorigin(domain):
|
|
return Milter.CONTINUE
|
|
# At this point, we know that the From: header contains a CSC address
|
|
logger.info('Rejecting message (MAIL FROM: %s, From: %s)' % (self.mailfrom, value))
|
|
# See RFC 1893
|
|
self.setreply('550', '5.7.1', REJECT_MESSAGE)
|
|
return Milter.REJECT
|
|
|
|
|
|
def check_postfix_user():
|
|
postfix_passwd = pwd.getpwnam('postfix')
|
|
uid = postfix_passwd.pw_uid
|
|
gid = postfix_passwd.pw_gid
|
|
if os.geteuid() != uid or os.getegid() != gid:
|
|
raise Exception('this program should be running as the Postfix user')
|
|
|
|
|
|
def get_postconf(param):
|
|
proc = subprocess.run(
|
|
rf"postconf -x | grep -oP '^{param} = \K(.*)'",
|
|
shell=True, capture_output=True, check=True
|
|
)
|
|
value = proc.stdout.decode().strip()
|
|
logger.debug(f'retrieved from postconf: {param} = {value}')
|
|
return value
|
|
|
|
|
|
def main():
|
|
global MYORIGIN
|
|
global MYNETWORKS
|
|
global RECEIVED_REGEX
|
|
|
|
check_postfix_user()
|
|
|
|
MYORIGIN = get_postconf('myorigin')
|
|
|
|
networks_str = get_postconf('mynetworks').replace(',', ' ')
|
|
networks = networks_str.split()
|
|
if os.path.isfile(CONFIG_PATH):
|
|
config = ConfigParser()
|
|
config.read_file(open(CONFIG_PATH))
|
|
if 'additional_subnets' in config['main']:
|
|
networks += config['main']['additional_subnets'].split(',')
|
|
MYNETWORKS = [
|
|
ipaddress.ip_network(
|
|
s.replace('[', '')
|
|
.replace(']', '')
|
|
.strip()
|
|
)
|
|
for s in networks
|
|
]
|
|
logger.debug('trusted subnets: %s' % MYNETWORKS)
|
|
re_myorigin = MYORIGIN.replace('.', r'\.')
|
|
RECEIVED_REGEX = re.compile(rf"\bby ([\w-]+\.)?{re_myorigin}\b")
|
|
|
|
Milter.factory = CSCMilter
|
|
logger.debug("milter startup")
|
|
Milter.runmilter("csc-milter", SOCKETPATH, TIMEOUT)
|
|
logger.debug("milter shutdown")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|