diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f615b97 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.swp +*.egg-info/ +build/ diff --git a/README.md b/README.md index 3752ac2..34619b0 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,61 @@ -# Spoof Milter -Postfix does not provide libmilter so you will need to install that -``` -apt install libmilter -``` -may be provided by sendmail-devel on some systems - -You will also need to install pymilter -``` -pip install pymilter -``` +# CSC Milter +This is a milter ("mail filter") for CSC. Currently it only has one purpose: +prevent clients outside of the UW network from setting the 'From' header +to a CSC address *and* using port 25 (port 587 is OK). ## Installation -in /etc/postfix/mail.cf +As root: ``` -smtpd_milters = inet:localhost: ...other filters... +apt install python3-milter +pip3 install . ``` -Write systemd thing to run milter +Installing python3-milter will also install libmilter as a dependency. + +Now open /etc/postfix/main.cf and add 'unix:/run/csc-milter/csc-milter.sock' +to the end of smptd_milters. Example: ``` -/some/where/opendkim -l -u userid -p inet:@localhost ...other options... +smtpd_milters = unix:/var/spool/postfix/spamass/spamass.sock unix:/run/csc-milter/csc-milter.sock +``` +Also open /etc/postfix/master.cf and explicitly set smtpd_milters *without* +csc-milter for the ports where csc-milter should not be running. Example: +``` +submission inet n - n - - smtpd + -o smtpd_sasl_auth_enable=yes + ... + -o smtpd_milters=unix:/var/spool/postfix/spamass/spamass.sock + ... +``` +Notice how smtpd_milters above does not have the csc-milter socket path. Therefore +csc-milter will not be invoked on messages arriving on port 587 (submission). + +Optional, but strongly recommended: modify milter\_connect\_macros in +/etc/postfix/main.cf to include `{daemon_port}`. This ensures that even if you +forget to exclude csc-milter from master.cf, clients using non-25 ports will not +be rejected. +Example: +``` +milter_connect_macros = j v _ {daemon_name} {daemon_port} ``` -### Other notes -Different milter settings for different client IP addresses +Optional, but recommended: add the following to /etc/postfix/main.cf: ``` -/etc/postfix/main.cf: - smtpd_milter_maps = cidr:/etc/postfix/smtpd_milter_map - smtpd_milters = inet:host:port, { inet:host:port, ... }, ... +smtpd_milter_maps = cidr:/etc/postfix/smtpd_milter_map +``` +Then, in /etc/postfix/smtpd\_milter\_map, add something like the following: +``` +127.0.0.0/8 DISABLE +192.168.0.0/16 DISABLE +::/64 DISABLE +2001:db8::/32 DISABLE +``` +This ensures that csc-milter will not be run on messages from local clients. +Replace 'DISABLE' by any additional milters which should be run. Note that +even if you do not do this, csc-milter will still accept messages from local +clients. -/etc/postfix/smtpd_milter_map: - # Disable Milters for local clients. - # do this for local waterloo - 127.0.0.0/8 DISABLE - 192.168.0.0/16 DISABLE - ::/64 DISABLE - 2001:db8::/32 DISABLE +## Tests +Run the following from the root directory: +``` +pip3 install -r test_requirements.txt +pytest ``` diff --git a/csc_milter/__init__.py b/csc_milter/__init__.py new file mode 100644 index 0000000..f30df23 --- /dev/null +++ b/csc_milter/__init__.py @@ -0,0 +1 @@ +from .main import CSCMilter diff --git a/csc_milter/main.py b/csc_milter/main.py new file mode 100644 index 0000000..6b350e5 --- /dev/null +++ b/csc_milter/main.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 + +import argparse +from email.utils import parseaddr +import ipaddress +import logging +import os +import pwd +import subprocess + +import Milter + + +SOCKETPATH = "/run/csc-milter/csc-milter.sock" +TIMEOUT = 5 +MYORIGIN = '' +MYNETWORKS = [] +REJECT_MESSAGE = 'You may not send from a CSC address on port 25 - please use port 587 instead.' + +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] + if is_in_mynetworks(self.IP): + return Milter.ACCEPT + self.daemon_port = self.getsymval('{daemon_port}') + if self.daemon_port is not None and self.daemon_port != '25': + return Milter.ACCEPT + return Milter.CONTINUE + + @Milter.noreply + def envfrom(self, mailfrom, *str): + self.mailfrom = mailfrom + return Milter.CONTINUE + + def header(self, field, value): + """Called for each header field in the message body.""" + if field.lower() != 'from': + return Milter.CONTINUE + addr = parseaddr(value)[1] + if addr == '': + return Milter.CONTINUE + domain = addr.split('@')[1].lower() + if is_in_myorigin(domain): + 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 + return Milter.CONTINUE + + +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 + + check_postfix_user() + + parser = argparse.ArgumentParser(description='Custom milter for CSC.') + parser.add_argument('--myorigin', help='domain name for locally-posted mail') + parser.add_argument('--mynetworks', help='space-separated list of trusted subnets') + args = parser.parse_args() + + if args.myorigin is not None: + MYORIGIN = args.myorigin + else: + MYORIGIN = get_postconf('myorigin') + if args.mynetworks is not None: + networks = args.mynetworks.split() + else: + networks = get_postconf('mynetworks').split() + MYNETWORKS = [ + ipaddress.ip_network(s.replace('[', '').replace(']', '')) + for s in networks + ] + + Milter.factory = CSCMilter + logger.debug("milter startup") + Milter.runmilter("csc-milter", SOCKETPATH, TIMEOUT) + logger.debug("milter shutdown") + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..88b918b --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +pymilter>=1.0.0 diff --git a/setup.py b/setup.py index f930170..b26e798 100644 --- a/setup.py +++ b/setup.py @@ -1,14 +1,41 @@ +import sys + from setuptools import setup, find_packages +from setuptools.command.test import test as TestCommand + +requirements = [line.strip() for line in open('requirements.txt')] +test_requirements = [line.strip() for line in open('test_requirements.txt')] +long_description = open('README.md').read() + + +class PyTest(TestCommand): + def run_tests(self): + import pytest + sys.exit(pytest.main([])) + setup( - name='milter_nospoof', - version='0.0.1', - description='rejects messages using csclub.uwaterloo.ca on port 25', - packages=find_packages('src'), - package_dir={'': 'src'}, - python_requires='>=3.0', - install_requires=[ - ], + name='csc-milter', + version='0.1.0', + description='Custom milter for CSC', + long_description=long_description, + long_description_content_type='text/markdown', + url='https://git.csclub.uwaterloo.ca/public/csc-milter.git', + author='CSC Systems Committee', + author_email='syscom@csclub.uwaterloo.ca', + classifiers=[ + 'Programming Language :: Python :: 3', + 'OSI Approved :: GNU General Public License v3 or later (GPLv3+)', + 'Operating System :: Unix', + ], + license='GPLv3', + keywords='email, milter', + packages=find_packages(), + python_requires='>=3.6', + install_requires=requirements, + tests_require=test_requirements, + cmdclass={'test': PyTest}, + entry_points={ + 'console_scripts': ['csc-milter=csc_milter.main:main'] + }, ) - - diff --git a/src/milter_nospoof/milter.py b/src/milter_nospoof/milter.py deleted file mode 100644 index 594cf56..0000000 --- a/src/milter_nospoof/milter.py +++ /dev/null @@ -1,48 +0,0 @@ -import Milter -from socket import AF_INET, AF_INET6 -from Milter.utils import parse_addr - -class myMilter(Milter.Base): - - def __init__(self): # A new instance with each new connection. - self.id = Milter.uniqueID() # Integer incremented with each call. - -# @Milter.noreply - # called on connect to MTA - def connect(self, IPname, family, hostaddr): - # IPname: the PTR name or bracketed IP of the SMTP client - # family: one of (socket.AF_INET, socket.AF_INET6, socket.AF_UNIX) - # hostaddr: a tuple or string with peer IP or socketname - # (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] - self.port = hostaddr[1] - if family == AF_INET6: - self.flow = hostaddr[2] - self.scope = hostaddr[3] - else: - self.flow = None - self.scope = None - self.IPname = IPname # Name from a reverse IP lookup - self.H = None - return Milter.CONTINUE - - # called when the SMTP client says MAIL FROM - def envfrom(self, mailfrom, *str): - uwaddr = "csclub.uwaterloo.ca" - if uwaddr in parse_addr(mailfrom) and self.IP == 25: - return Milter.REJECT # this will only drop the msg not the connection - return Milter.CONTINUE - - -def main(): - socketname = "/var/run/milter-nospoof" - timeout = 600 - # Register to have the Milter factory create instances of your class: - Milter.factory = myMilter -# print "%s milter startup" % time.strftime('%Y%b%d %H:%M:%S') -# sys.stdout.flush() -# Milter.runmilter("pythonfilter",socketname,timeout) -# print "%s bms milter shutdown" % time.strftime('%Y%b%d %H:%M:%S') - if __name__ == "__main__": - main() diff --git a/test_requirements.txt b/test_requirements.txt new file mode 100644 index 0000000..7246db8 --- /dev/null +++ b/test_requirements.txt @@ -0,0 +1 @@ +pytest>=6.2.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_from_header.py b/tests/test_from_header.py new file mode 100644 index 0000000..dd98b9b --- /dev/null +++ b/tests/test_from_header.py @@ -0,0 +1,62 @@ +import ipaddress +import socket +from unittest.mock import Mock + +import Milter + +import csc_milter.main as csc_milter + + +def setup_module(): + csc_milter.MYNETWORKS = [ + ipaddress.ip_network('129.97.134.0/24'), + ] + csc_milter.MYORIGIN = 'csclub.uwaterloo.ca' + + +def teardown_module(): + csc_milter.MYNETWORKS = [] + csc_milter.MYORIGIN = '' + + +def get_milter(dst_port): + milter = csc_milter.CSCMilter() + milter.getsymval = Mock(side_effect=lambda s: str(dst_port) if s == '{daemon_port}' else None) + milter._protocol = 18266 # to make the @Milter.noreply work + milter.setreply = Mock() + return milter + + +def test_local_port_25(): + milter = get_milter(25) + ret = milter.connect('caffeine.csclub.uwaterloo.ca', socket.AF_INET, ('129.97.134.17', 10000)) + assert ret == Milter.ACCEPT + + +def test_local_port_587(): + milter = get_milter(587) + ret = milter.connect('caffeine.csclub.uwaterloo.ca', socket.AF_INET, ('129.97.134.17', 10000)) + assert ret == Milter.ACCEPT + + +def test_remote_port_25_csc_address(): + milter = get_milter(25) + ret = milter.connect('[24.114.29.182]', socket.AF_INET, ('24.114.29.182', 10000)) + assert ret == Milter.CONTINUE + milter.envfrom('') + ret = milter.header('From', '') + assert ret == Milter.REJECT + + +def test_remote_port_25_non_csc_address(): + milter = get_milter(25) + milter.connect('[24.114.29.182]', socket.AF_INET, ('24.114.29.182', 10000)) + milter.envfrom('') + ret = milter.header('From', 'John Doe ') + assert ret == Milter.CONTINUE + + +def test_remote_port_587(): + milter = get_milter(587) + ret = milter.connect('[24.114.29.182]', socket.AF_INET, ('24.114.29.182', 10000)) + assert ret == Milter.ACCEPT