From 1044cabfef9238cee01a46cd6a94e04adb5ac3c3 Mon Sep 17 00:00:00 2001
From: Julian Rother <julian@cccv.de>
Date: Tue, 19 Apr 2022 19:58:51 +0200
Subject: [PATCH] Remailer support

Adds "remailer_canonical" request that allows canonical_map address rewriting
with Postfix for uffd's remailer feature.
---
 test_server.py  | 75 +++++++++++++++++++++++++++++++++++++++++++++++--
 uffd-socketmapd | 23 +++++++++++++--
 2 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/test_server.py b/test_server.py
index 5da2692..2444e72 100644
--- a/test_server.py
+++ b/test_server.py
@@ -60,14 +60,18 @@ class MockConnection:
 		pass
 
 class UffdAPIMock:
-	def __init__(self, aliases=None):
+	def __init__(self, aliases=None, remailer_map=None):
 		self.aliases = aliases or {}
+		self.remailer_map = remailer_map or {}
 
 	def get_aliases(self, mail_address):
 		return self.aliases.get(mail_address, [])
 
+	def resolve_remailer(self, orig_address):
+		return self.remailer_map.get(orig_address, None)
+
 class TestSocketmapsRequestHandler(unittest.TestCase):
-	def test_handle(self):
+	def test_handle_virtual(self):
 		api = UffdAPIMock(aliases={
 			'test@example.com': ['test-dest@example.com'],
 			'space test@example.com': ['space test dest@example.com'],
@@ -136,3 +140,70 @@ class TestSocketmapsRequestHandler(unittest.TestCase):
 		conn = MockConnection(request, 4096)
 		RequestHandler(conn, '', None).handle()
 		self.assertIn(b'PERM ', conn.sent)
+
+	def test_handle_remailer(self):
+		api = UffdAPIMock(remailer_map={
+			'v1-23-testuser@remailer.example.com': 'testuser@example.com',
+			'v1-23-testadmin@remailer.example.com': 'testadmin@example.com',
+			# Artifical entry to test if map is queried (see _with_remailer_domain case)
+			'foobar@test.example.com': 'DOESNOTEXIT',
+		})
+		RequestHandler = make_requesthandler(api)
+
+		request = b'54:remailer_canonical v1-23-testuser@remailer.example.com,'
+		response = b'23:OK testuser@example.com,'
+		conn = MockConnection(request, 4096)
+		RequestHandler(conn, '', None).handle()
+		self.assertEqual(conn.sent, response)
+
+		request = b'56:remailer_canonical v1-42-not-a-user@remailer.example.com,'
+		response = b'9:NOTFOUND ,'
+		conn = MockConnection(request, 4096)
+		RequestHandler(conn, '', None).handle()
+		self.assertEqual(conn.sent, response)
+
+		request = b'35:remailer_canonical test@example.com,'
+		response = b'9:NOTFOUND ,'
+		conn = MockConnection(request, 4096)
+		RequestHandler(conn, '', None).handle()
+		self.assertEqual(conn.sent, response)
+
+		# See _with_remailer_domain case
+		request = b'42:remailer_canonical foobar@test.example.com,'
+		response = b'14:OK DOESNOTEXIT,'
+		conn = MockConnection(request, 4096)
+		RequestHandler(conn, '', None).handle()
+		self.assertEqual(conn.sent, response)
+
+	def test_handle_remailer_with_remailer_domain(self):
+		api = UffdAPIMock(remailer_map={
+			'v1-23-testuser@remailer.example.com': 'testuser@example.com',
+			'v1-23-testadmin@remailer.example.com': 'testadmin@example.com',
+			# Artifical entry to test if map is queried
+			'foobar@test.example.com': 'DOESNOTEXIT',
+		})
+		RequestHandler = make_requesthandler(api, remailer_domain='remailer.example.com')
+
+		request = b'54:remailer_canonical v1-23-testuser@remailer.example.com,'
+		response = b'23:OK testuser@example.com,'
+		conn = MockConnection(request, 4096)
+		RequestHandler(conn, '', None).handle()
+		self.assertEqual(conn.sent, response)
+
+		request = b'56:remailer_canonical v1-42-not-a-user@remailer.example.com,'
+		response = b'9:NOTFOUND ,'
+		conn = MockConnection(request, 4096)
+		RequestHandler(conn, '', None).handle()
+		self.assertEqual(conn.sent, response)
+
+		request = b'35:remailer_canonical test@example.com,'
+		response = b'9:NOTFOUND ,'
+		conn = MockConnection(request, 4096)
+		RequestHandler(conn, '', None).handle()
+		self.assertEqual(conn.sent, response)
+
+		request = b'42:remailer_canonical foobar@test.example.com,'
+		response = b'9:NOTFOUND ,'
+		conn = MockConnection(request, 4096)
+		RequestHandler(conn, '', None).handle()
+		self.assertEqual(conn.sent, response)
diff --git a/uffd-socketmapd b/uffd-socketmapd
index 76d151a..58397a1 100755
--- a/uffd-socketmapd
+++ b/uffd-socketmapd
@@ -30,6 +30,11 @@ class UffdAPI:
 		logger.debug('API getmails response for %s: %s', repr(mail_address), destinations)
 		return destinations
 
+	def resolve_remailer(self, orig_address):
+		'''Return real mail address for a given remailer mail address or None'''
+		result = self.get('/api/v1/resolve-remailer', orig_address=orig_address)
+		return result['address']
+
 # From https://cr.yp.to/proto/netstrings.txt
 #
 # Any string of 8-bit bytes may be encoded as [len]":"[string]",".
@@ -118,6 +123,7 @@ def decode_netstring(netstring):
 
 class SocketmapsRequestHandler(socketserver.BaseRequestHandler):
 	api = None # Overwritten
+	remailer_domain = None # Overwritten
 
 	def handle(self):
 		buf = b''
@@ -188,12 +194,22 @@ class SocketmapsRequestHandler(socketserver.BaseRequestHandler):
 			# matter, since Postfix internally processes lookup results as a
 			# comma-separated list.
 			return 'OK ' + ','.join(results)
+		elif name == 'remailer_canonical':
+			domain = key.rsplit('@', 1)[-1]
+			# If self.remailer_domain is unset, uffd will do the filtering
+			if self.remailer_domain is not None and domain != self.remailer_domain:
+				return 'NOTFOUND '
+			result = self.api.resolve_remailer(key)
+			if result is None:
+				return 'NOTFOUND '
+			return 'OK ' + result
 		return 'PERM Unknown request name', logging.WARNING
 
-def make_requesthandler(api):
+def make_requesthandler(api, remailer_domain=None):
 	class RequestHandler(SocketmapsRequestHandler):
 		pass
 	RequestHandler.api = api
+	RequestHandler.remailer_domain = remailer_domain
 	return RequestHandler
 
 class FilenoUnixStreamServer(socketserver.UnixStreamServer):
@@ -229,7 +245,8 @@ class StdoutFilter(logging.Filter):
 @click.option('--api-url', required=True, help='Uffd base URL without API prefix or trailing slash (e.g. https://example.com)')
 @click.option('--api-user', required=True, help='API user/client id')
 @click.option('--api-secret', required=True, help='API secret, do not set this on the command-line, use environment variable SERVER_API_SECRET instead')
-def main(socket_path, socket_fd, api_url, api_user, api_secret):
+@click.option('--remailer-domain', help='Domain to filter remailer lookups locally')
+def main(socket_path, socket_fd, api_url, api_user, api_secret, remailer_domain):
 	if (socket_path is None and socket_fd is None) or \
 	   (socket_path is not None and socket_fd is not None):
 		raise click.ClickException('Either --socket-path or --socket-fd must be specified')
@@ -244,7 +261,7 @@ def main(socket_path, socket_fd, api_url, api_user, api_secret):
 	logger.addHandler(stderr_handler)
 
 	api = UffdAPI(api_url, api_user, api_secret)
-	RequestHandler = make_requesthandler(api)
+	RequestHandler = make_requesthandler(api, remailer_domain)
 	if socket_path is not None:
 		cleanup_unix_socket(socket_path)
 		socketserver.ThreadingUnixStreamServer(socket_path, RequestHandler).serve_forever()
-- 
GitLab