summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAlec Warner <antarus@gentoo.org>2019-08-09 10:59:32 -0700
committerAlec Warner <antarus@gentoo.org>2019-08-09 11:07:13 -0700
commita7845d51381a4dd89e660984ccbd7ca3aa8138ba (patch)
treeb1aa98b450b404653aac60ef68d22e87d55fb04e
parentAdd target pool for our loadbalancer. (diff)
downloadantarus-a7845d51381a4dd89e660984ccbd7ca3aa8138ba.tar.gz
antarus-a7845d51381a4dd89e660984ccbd7ca3aa8138ba.tar.bz2
antarus-a7845d51381a4dd89e660984ccbd7ca3aa8138ba.zip
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 <antarus@gentoo.org>
-rw-r--r--src/infra.gentoo.org/milters/mlmmj.py107
-rw-r--r--src/infra.gentoo.org/milters/subscribed.py52
2 files changed, 159 insertions, 0 deletions
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)