From a7845d51381a4dd89e660984ccbd7ca3aa8138ba Mon Sep 17 00:00:00 2001 From: Alec Warner Date: Fri, 9 Aug 2019 10:59:32 -0700 Subject: Add PoC for milter. TODO: rcpt_to likely needs some canonicalization (lists.g.o vs g.o, some other stuff. Signed-off-by: Alec Warner --- src/infra.gentoo.org/milters/mlmmj.py | 107 +++++++++++++++++++++++++++++ src/infra.gentoo.org/milters/subscribed.py | 52 ++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 src/infra.gentoo.org/milters/mlmmj.py create mode 100644 src/infra.gentoo.org/milters/subscribed.py diff --git a/src/infra.gentoo.org/milters/mlmmj.py b/src/infra.gentoo.org/milters/mlmmj.py new file mode 100644 index 0000000..5a99c92 --- /dev/null +++ b/src/infra.gentoo.org/milters/mlmmj.py @@ -0,0 +1,107 @@ +"""Module mlmmj implements some wrappers around mlmmj.""" + +import threading + +# We assume that this will happen in global scope at import time before the milter is serving. +SINGLETON_MLMMJ = MlmmjConfig(source=MlmmjSource()) + +def GetSingletonConfig(): + global SINGLETON_MLMMJ + return SINGLETON_MLMMJ + + +class MlmmjConfig(object): + """Contains the config for mlmmj. + + The config supports looking up if an address is a mailing list. + The config supports looking up if an address is subscribed to a list. + The config is reloaded after every refresh_count lookups + or refresh_time seconds. + + This is designed to be used by a postfix milter where multiple milters + will share one instance of this config and the result is that this + class should be thread-safe. + """ + + def __init__(self, source, refresh_time=600, refresh_count=10000): + self.source = source + self.refresh_time = refresh_time + self.refresh_count = refresh_count + self.lock = threading.Lock() + self.subscribers = source.GetSubscribers() + + def IsMailingList(self, address): + with self.lock: + return address in self.subscribers + + def IsSubscribed(self, subscriber_address, list_name): + with self.lock: + if list_name not in self.subscribers: + return False + return subscriber_address in self.subscribers[list_name].subscribers + + +class MlmmjSource(object): + """This is an interface to interacting with mlmmj directly. + + Because the milter will call "IsList" and "IsSubscribed" we want to avoid + letting external calls touch the filesystem. A trivial implementation might + be: + + def IsList(address): + return os.path.exists(os.path.join(list_path, address)) + + But IMHO this is very leaky and naughty people could potentially try to use + it to do bad things. Instead we control the filesystem accesses as well as + invocations of mlmmj-list ourselves. + """ + + # The value in our subscribers dict is a set of mtimes and a subscriber list. + # We only update the subscribers when the mtimes are mismatched. + MLData = collections.namedtuple('MLData', ['mtimes', 'subscribers']) + + def __init__(self, list_path='/var/lists'): + self.list_path = list_path + self.subscribers = {} + Update() + + def Update(self): + lists = os.listdir(list_path) + # /var/lists on the mailing lists server is messy; filter out non directories. + # /var/lists has a RETIRED directory, filter that out too. + lists = [f for f in lists if os.path.isdir(f) and f != 'RETIRED'] + # In case there are 'extra' directories; use LISTNAME/control as a sentinel value for + # "this directory probably contains an mlmmj list heirarchy." + lists = [f for f in lists if not os.path.exists(os.path.join(f, 'control')] + for ml in lists: + mtimes = MlmmjSource._GetMTimes(self.list_path, ml) + if ml in self.subscribers: + if self.subscribers.mtimes == mtimes: + # mtimes are up to date, we have the latest subscriber list for this ML + continue + subscribers = MlmmjSource._GetSubscribers(self.list_path, ml) + self.subscribers[ml] = MLData(mtimes=mtimes, subscribers=subscribers) + + @staticmethod + def _GetSubscribers(list_path, listname): + # -s is the normal subscriber list. + data = subprocess.check_output(['mlmmj-list', '-L', os.path.join(list_path, list_name), '-s']) + # -d is the digest subscribers list. + data += subprocess.check_output(['mlmmj-list', '-L', os.path.join(list_path, list_name), '-d']) + # -n is the nomail subscribers list. + data += subprocess.check_output(['mlmmj-list', '-L', os.path.join(list_path, list_name), '-n']) + # check_output returns bytes, convert to string so we can split on '\n' + data = data.decode('utf-8') + data = data.strip() + return data.split('\n') + + @staticmethod + def _GetMTimes(list_path, listname): + dirs = ('digesters.d', 'nomailsubs.d', 'subscribers.d') + mtimes = [] + for d in dirs: + try: + mtimes.append(os.stat(os.path.join(list_path, listname, d)).st_mtime) + except EnvironmentError: + pass + return mtimes diff --git a/src/infra.gentoo.org/milters/subscribed.py b/src/infra.gentoo.org/milters/subscribed.py new file mode 100644 index 0000000..dc5d842 --- /dev/null +++ b/src/infra.gentoo.org/milters/subscribed.py @@ -0,0 +1,52 @@ +"""Reject messages at RCPT_TO time if the envelop sender is not subscribed. + +Gentoo's mlmmj is configured to silently drop messages if the poster is not +subscribed (including no bounces) to prevent backscatter. This milter is +intended to work around this problem by rejecting messages at message-send +time. We do this by: + + - Looking at RCPT TO headers to see where the message is headed. + - If its an mlmmj list, checking the subscriber list. + - If the envelop sender is not subscribed, reject the message rather + than accepting it for delivery. + +This is accomplished with a sendmail compatible milter that receives +callbacks for every message seen by postfix and computing some extra logic +to enforce these rules. +""" + +import os +import Milter +import mlmmj + +def SubscribedMilterFactory(): + """Return a SubscribedMilter with a MlmmjSource configured.""" + inst = mlmmj.GetSingletonConfig() + return SubscribedMilter(mlmmj_config=inst) + + +class SubscribedMilter(Milter.Base): + """Rejects messages at accept time if address is not subscribed.""" + + def __init__(self, mlmmj_config): + self.mlmmj_config = mlmmj_config + + def envfrom(self, mailfrom, *args): + # Store the envelope sender for later computation + self.mailfrom = mailfrom + return Milter.CONTINUE + + def envrcpt(self, to, *args): + if mlmmj_config.IsMailingList(to): + if mlmmj_config.IsSubscribed(self.mailfrom, to): + return Milter.ACCEPT + else: + self.setreply('550', '5.7.1', + '%s is not a subscriber to %d; please subscribe to send messages to this list.' %(self.mailfrom, to)) + return Milter.REJECT + +if __name__ == "__main__": + socketname = "/var/run/mlmmj-milter.sock" + timeout = 600 + Milter.factory = SubscribedMilterFactory + Milter.runmilter("MlmmjMilter", socketname, timeout) -- cgit v1.2.3