From b931543508377c0e48a6801e4ea217eb523e2b03 Mon Sep 17 00:00:00 2001 From: Gregor Kleen Date: Tue, 13 Sep 2022 10:29:35 +0200 Subject: ... --- .../ccert_policy_server/__init__.py | 0 .../ccert_policy_server/__main__.py | 92 ++++++++++++++++++++++ hosts/surtr/email/ccert-policy-server/setup.py | 12 +++ hosts/surtr/email/default.nix | 88 +++++++++++++++++++-- 4 files changed, 185 insertions(+), 7 deletions(-) create mode 100644 hosts/surtr/email/ccert-policy-server/ccert_policy_server/__init__.py create mode 100644 hosts/surtr/email/ccert-policy-server/ccert_policy_server/__main__.py create mode 100644 hosts/surtr/email/ccert-policy-server/setup.py (limited to 'hosts/surtr/email') diff --git a/hosts/surtr/email/ccert-policy-server/ccert_policy_server/__init__.py b/hosts/surtr/email/ccert-policy-server/ccert_policy_server/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hosts/surtr/email/ccert-policy-server/ccert_policy_server/__main__.py b/hosts/surtr/email/ccert-policy-server/ccert_policy_server/__main__.py new file mode 100644 index 00000000..f481090c --- /dev/null +++ b/hosts/surtr/email/ccert-policy-server/ccert_policy_server/__main__.py @@ -0,0 +1,92 @@ +from systemd.daemon import listen_fds +from sdnotify import SystemdNotifier +from socketserver import StreamRequestHandler, ThreadingMixIn +from systemd_socketserver import SystemdSocketServer +import sys +from threading import Thread +from psycopg_pool import ConnectionPool +from psycopg.rows import namedtuple_row + +import logging + + +class PolicyHandler(StreamRequestHandler): + def handle(self): + logger.debug('Handling new connection...') + + self.args = dict() + + line = None + while line := self.rfile.readline().removesuffix(b'\n'): + if b'=' not in line: + break + + key, val = line.split(sep=b'=', maxsplit=1) + self.args[key.decode()] = val.decode() + + logger.info('Connection parameters: %s', self.args) + + allowed = False + with self.server.db_pool.connection() as conn: + local, domain = self.args['sender'].split(sep='@', maxsplit=1) + extension = None + if '+' in local: + local, extension = local.split(sep='+', maxsplit=1) + + logger.debug('Parsed address: %s', {'local': local, 'extension': extension, 'domain': domain}) + + with conn.cursor() as cur: + cur.row_factory = namedtuple_row + cur.execute('SELECT "mailbox"."mailbox" as "user", "local", "extension", "domain" FROM "mailbox" INNER JOIN "mailbox_mapping" ON "mailbox".id = "mailbox_mapping"."mailbox" WHERE "mailbox"."mailbox" = %(user)s AND ("local" = %(local)s OR "local" IS NULL) AND ("extension" = %(extension)s OR "extension" IS NULL) AND "domain" = %(domain)s', params = {'user': self.args['ccert_subject'], 'local': local, 'extension': extension if extension is not None else '', 'domain': domain}, prepare=True) + for record in cur: + logger.debug('Received result: %s', record) + allowed = True + + action = '550 5.7.0 Sender address not authorized for current user' + if allowed: + action = 'DUNNO' + + logger.info('Reached verdict: %s', {'allowed': allowed, 'action': action}) + self.wfile.write(f'action={action}\n\n'.encode()) + +class ThreadedSystemdSocketServer(ThreadingMixIn, SystemdSocketServer): + def __init__(self, fd, RequestHandlerClass): + super().__init__(fd, RequestHandlerClass) + + self.db_pool = ConnectionPool(min_size=1) + self.db_pool.wait() + +def main(): + global logger + logger = logging.getLogger(__name__) + console_handler = logging.StreamHandler() + console_handler.setFormatter( logging.Formatter('[%(levelname)s](%(name)s): %(message)s') ) + if sys.stderr.isatty(): + console_handler.setFormatter( logging.Formatter('%(asctime)s [%(levelname)s](%(name)s): %(message)s') ) + logger.addHandler(console_handler) + logger.setLevel(logging.DEBUG) + + # log uncaught exceptions + def log_exceptions(type, value, tb): + global logger + + logger.error(value) + sys.__excepthook__(type, value, tb) # calls default excepthook + + sys.excepthook = log_exceptions + + fds = listen_fds() + servers = [ThreadedSystemdSocketServer(fd, PolicyHandler) for fd in fds] + + if servers: + for server in servers: + Thread(name=f'Server for fd{server.fileno()}', target=server.serve_forever).start() + else: + return 2 + + SystemdNotifier().notify('READY=1') + + return 0 + +if __name__ == '__main__': + sys.exit(main()) diff --git a/hosts/surtr/email/ccert-policy-server/setup.py b/hosts/surtr/email/ccert-policy-server/setup.py new file mode 100644 index 00000000..d8eb415a --- /dev/null +++ b/hosts/surtr/email/ccert-policy-server/setup.py @@ -0,0 +1,12 @@ +from setuptools import setup, find_packages + +setup( + name = 'ccert-policy-server', + version = '0.0.0', + packages = ['ccert_policy_server'], + entry_points = { + 'console_scripts': [ + 'ccert-policy-server=ccert_policy_server.__main__:main' + ], + }, +) diff --git a/hosts/surtr/email/default.nix b/hosts/surtr/email/default.nix index 83bf02f5..9cfba1f1 100644 --- a/hosts/surtr/email/default.nix +++ b/hosts/surtr/email/default.nix @@ -1,4 +1,4 @@ -{ config, pkgs, lib, ... }: +{ config, pkgs, lib, flakeInputs, ... }: with lib; @@ -20,6 +20,27 @@ let ''; }; + ccert-policy-server = flakeInputs.mach-nix.lib.${config.nixpkgs.system}.buildPythonPackage { + src = ./ccert-policy-server; + pname = "ccert-policy-server"; + version = "0.0.0"; + + python = "python39"; + ignoreDataOutdated = true; + + requirements = '' + sdnotify + systemd-socketserver + psycopg >=3.0.0 + psycopg-pool >=3.0.0 + psycopg-binary >=3.0.0 + ''; + + overridesPre = [ + (self: super: { systemd-python = super.systemd.overrideAttrs (oldAttrs: { pname = "systemd-python"; }); }) + ]; + }; + spmDomains = ["bouncy.email"]; in { config = { @@ -35,7 +56,7 @@ in { }; }) ]; - + services.postfix = { enable = true; hostname = "surtr.yggdrasil.li"; @@ -187,8 +208,9 @@ in { "-o" "smtpd_tls_ask_ccert=yes" "-o" "smtpd_tls_req_ccert=yes" "-o" "smtpd_client_restrictions=permit_tls_all_clientcerts,reject" + "-o" "{smtpd_data_restrictions = check_policy_service unix:/run/postfwd3/postfwd3.sock}" "-o" "smtpd_relay_restrictions=permit_tls_all_clientcerts,reject" - "-o" "smtpd_sender_restrictions=reject_unknown_sender_domain,reject_unverified_sender" + "-o" "{smtpd_sender_restrictions = reject_unknown_sender_domain,reject_unverified_sender,check_policy_service unix:/run/postfix-ccert-sender-policy.sock}" "-o" "unverified_sender_reject_code=550" "-o" "unverified_sender_reject_reason={Sender address rejected: undeliverable address}" "-o" "smtpd_recipient_restrictions=reject_unauth_pipelining,reject_non_fqdn_recipient,reject_unknown_recipient_domain,permit_tls_all_clientcerts,reject" @@ -415,7 +437,7 @@ in { mail_plugins = $mail_plugins quota mailbox_list_index = yes postmaster_address = postmaster@yggdrasil.li - recipient_delimiter = + recipient_delimiter = auth_username_chars = abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567890.-+_@ service lmtp { @@ -431,7 +453,7 @@ in { namespace inbox { separator = / inbox = yes - prefix = + prefix = mailbox Trash { auto = no @@ -602,7 +624,7 @@ in { ${pkgs.dovecot_pigeonhole}/bin/sievec $f done ''; - + serviceConfig = { LoadCredential = [ "surtr.yggdrasil.li.key.pem:${config.security.acme.certs."surtr.yggdrasil.li".directory}/key.pem" @@ -703,7 +725,7 @@ in { }; systemd.sockets.spm = { wantedBy = [ "nginx.service" ]; - + socketConfig = { ListenStream = "/run/spm/server.sock"; SocketUser = "spm"; @@ -730,5 +752,57 @@ in { enable = true; loglevel = "debug"; }; + + systemd.sockets."postfix-ccert-sender-policy" = { + requiredBy = ["postfix.service"]; + wants = ["postfix-ccert-sender-policy.service"]; + socketConfig = { + ListenStream = "/run/postfix-ccert-sender-policy.sock"; + }; + }; + systemd.services."postfix-ccert-sender-policy" = { + serviceConfig = { + Type = "notify"; + + ExecStart = "${ccert-policy-server}/bin/ccert-policy-server"; + + Environment = [ + "PGDATABASE=email" + ]; + + DynamicUser = false; + User = "postfix-ccert-sender-policy"; + Group = "postfix-ccert-sender-policy"; + ProtectSystem = "strict"; + SystemCallFilter = "@system-service"; + NoNewPrivileges = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectKernelLogs = true; + ProtectControlGroups = true; + MemoryDenyWriteExecute = true; + RestrictSUIDSGID = true; + KeyringMode = "private"; + ProtectClock = true; + RestrictRealtime = true; + PrivateDevices = true; + PrivateTmp = true; + ProtectHostname = true; + ReadWritePaths = ["/run/postgresql"]; + }; + }; + users.users."postfix-ccert-sender-policy" = { + isSystemUser = true; + group = "postfix-ccert-sender-policy"; + }; + users.groups."postfix-ccert-sender-policy" = {}; + + services.postfwd = { + enable = true; + rules = '' + id=RCPT01; protocol_state=DATA; protocol_state=END-OF-MESSAGE; action=rcpt(ccert_subject/100/3600/450 4.7.1 Exceeding maximum of 100 recipients per hour [$$ratecount]) + id=RCPT02; protocol_state=DATA; protocol_state=END-OF-MESSAGE; action=rcpt(ccert_subject/1000/86400/450 4.7.1 Exceeding maximum of 1000 recipients per day [$$ratecount]) + ''; + }; }; } -- cgit v1.2.3