csc-milter/csc_milter/main.py

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